React

This page builds a shared React game from scratch: a schema, a room, and the two hooks that put irtio into a component tree. The scene uses react-three-fiber. It assumes you know React hooks.

Install

npx irtio init      # adds @irtio/schema, @irtio/server, @irtio/client and @irtio/testing
npm install
npm install react react-dom three @react-three/fiber
npm install -D @types/react @types/react-dom @types/three

irtio init writes irtio/schema.ts, irtio/room.ts, irtio/room.test.ts and irtio.json. The project id in them is public and domain locked, so it is safe in client code.

The loop

react-three-fiber keeps the render loop, and useFrame puts your code inside it. irtio has no callback for state changes, so you read the room there. Split the work in two.

ChangesOwnerHow
who is in the roomReactone component per presence record, mounted and unmounted
where they are, this framethe frame loopuseFrame reads room.render, writes to a ref

Room state changes twenty times a second. Putting it in React state re-renders the tree twenty times a second. Keep values out of React state and write them onto the object you are drawing.

Read pathWhat it gives youWhere to use it
room.statewhat the server last said, plus your own local writesyour own input, game logic, tests
room.renderthe same shape, with non-owned instances a beat behind arrival and their numeric fields interpolatedpositioning meshes in useFrame

A shared arena

Schema

// irtio/schema.ts
import { defineSchema, entity, f32 } from '@irtio/schema';

export const HALF = 20;   // arena half-width, world units

export const schema = defineSchema(
  {
    // One instance per client, owned by the client that joined as it. No name field: the
    // built-in presence collection already carries it, as room.clients.
    players: entity({ x: f32, z: f32, ry: f32 }),
  },
  {
    project: 'p_c0ffee1234abcd56',   // written by `irtio init`
    roles: ['player'] as const,
  },
);

Room file

// irtio/room.ts
import { defineRoom } from '@irtio/server';

import { HALF, schema } from './schema.js';

const MAX_STEP = 4;   // furthest a player may travel between two writes, world units

const clamp = (v: number) => Math.min(HALF, Math.max(-HALF, v));

export default defineRoom(schema, {
  mode: 'tick',
  tickRate: 20,
  maxClients: 16,

  onJoin(state, ctx) {
    if (ctx.reconnecting) return;
    state.players.add(
      ctx.clientId,
      { x: (Math.random() - 0.5) * 2 * HALF, z: (Math.random() - 0.5) * 2 * HALF, ry: 0 },
      { owner: ctx.clientId },   // this is what lets the client write the instance
    );
  },

  onLeave(state, ctx) {
    state.players.remove(ctx.clientId);
  },

  validate: {
    players(prev, next) {
      if (!Number.isFinite(next.x) || !Number.isFinite(next.z)) return prev;
      if (Math.hypot(next.x - prev.x, next.z - prev.z) > MAX_STEP) return prev;
      return { ...next, x: clamp(next.x), z: clamp(next.z) };
    },
  },

  // Tick mode needs a tick, even an empty one. Nothing here moves on its own.
  tick() {},
});

Hooks

// room-context.tsx
import { joinRoom } from '@irtio/client';
import type { PresenceRecord, Room } from '@irtio/client';
import { createContext, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';

import { schema } from './irtio/schema.js';

export type GameRoom = Room<typeof schema>;

const RoomContext = createContext<GameRoom | null>(null);

export function RoomProvider({ children }: { children: ReactNode }) {
  const [room, setRoom] = useState<GameRoom | null>(null);

  useEffect(() => {
    let live = true;
    let joined: GameRoom | undefined;
    joinRoom(schema, { role: 'player', name: 'you' }).then((r) => {
      joined = r;
      // Strict Mode runs this effect twice in development. The second room is dropped here.
      if (live) setRoom(r);
      else r.leave();
    });
    return () => {
      live = false;
      joined?.leave();
    };
  }, []);

  if (!room) return <p>connecting</p>;
  return <RoomContext.Provider value={room}>{children}</RoomContext.Provider>;
}

export function useRoom(): GameRoom {
  const room = useContext(RoomContext);
  if (!room) throw new Error('useRoom used outside RoomProvider');
  return room;
}

/** Presence changes on a join or a leave, which is rare enough to be React state.
 *  room.on('status', ...) and room.on('rtt', ...) take the same shape. */
export function useClients(): readonly PresenceRecord[] {
  const room = useRoom();
  const [clients, setClients] = useState<readonly PresenceRecord[]>(() => room.clients);
  useEffect(() => {
    setClients(room.clients);
    return room.on('clients', setClients);   // room.on returns its own unsubscribe
  }, [room]);
  return clients;
}

Components

// app.tsx
import { Canvas, useFrame } from '@react-three/fiber';
import { useEffect, useRef } from 'react';
import type { Mesh } from 'three';

import { RoomProvider, useClients, useRoom } from './room-context.js';
import type { GameRoom } from './room-context.js';

const SPEED = 8;   // world units per second

export default function App() {
  return (
    <RoomProvider>
      <Game />
    </RoomProvider>
  );
}

function Game() {
  const room = useRoom();
  const clients = useClients();   // re-renders on a join or a leave, and on nothing else
  return (
    <Canvas camera={{ position: [0, 14, 18], fov: 60 }}>
      <hemisphereLight args={[0xffffff, 0x334455, 2]} />
      <LocalInput room={room} />
      {/* One component per client. React handles the join and the leave. */}
      {clients.map((c) => (
        <Player key={c.clientId} room={room} id={c.clientId} />
      ))}
    </Canvas>
  );
}

function Player({ room, id }: { room: GameRoom; id: string }) {
  const mesh = useRef<Mesh>(null);

  // Every synced field is read here, so nothing the room changes needs a re-render.
  // room.render: remote players glide between ticks instead of stepping at 20 Hz.
  useFrame(() => {
    const player = room.render.players.get(id);
    if (!player || !mesh.current) return;
    mesh.current.position.set(player.x, 0.5, player.z);
    mesh.current.rotation.y = player.ry;
  });

  return (
    <mesh ref={mesh}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color={id === room.me ? '#ffd166' : '#4dabf7'} />
    </mesh>
  );
}

function LocalInput({ room }: { room: GameRoom }) {
  const keys = useRef(new Set<string>());

  useEffect(() => {
    const down = (e: KeyboardEvent) => keys.current.add(e.key.toLowerCase());
    const up = (e: KeyboardEvent) => keys.current.delete(e.key.toLowerCase());
    addEventListener('keydown', down);
    addEventListener('keyup', up);
    return () => {
      removeEventListener('keydown', down);
      removeEventListener('keyup', up);
    };
  }, []);

  useFrame((_state, dt) => {
    // room.state, not room.render: this is the instance you write.
    const me = room.state.players[room.me];
    if (!me) return;   // the instance arrives a frame or two after the join resolves
    const held = keys.current;
    const dx = (held.has('d') ? 1 : 0) - (held.has('a') ? 1 : 0);
    const dz = (held.has('s') ? 1 : 0) - (held.has('w') ? 1 : 0);
    if (dx === 0 && dz === 0) return;
    const len = Math.hypot(dx, dz);
    me.x += (dx / len) * SPEED * dt;   // owned write, batched into one update per frame
    me.z += (dz / len) * SPEED * dt;
    me.ry = Math.atan2(dx, dz);
  });

  return null;
}

Run npx irtio dev, open the page, copy the URL once it carries ?room=CODE, and paste it into a second tab.

Engine-specific notes

Presence drives mounting. Every room carries a built-in clients collection, read as room.clients. Each record is { clientId, role, name, connected } and it needs nothing in your schema, which is why the schema above has no name field.

room.clients is not a useSyncExternalStore snapshot. It builds a fresh array on every read, so a getSnapshot of () => room.clients returns a new value every time React checks and never settles. Subscribe to the clients event and hold the array in useState, as useClients does. room.state has no subscription at all, and reading it is a frame-loop job.

Strict Mode. React runs effects twice in development. joinRoom is async, so the guard is a live flag plus leave() on the room that arrives after the cleanup ran. Without it the room counts two clients for one browser tab.

Coordinate system. react-three-fiber is Three.js: y up, right handed, and element props such as args are the constructor arguments of the underlying object. MAX_STEP in irtio/room.ts and SPEED in your component have to agree about the unit.

Cleanup. react-three-fiber disposes the geometries and materials it created for a component when that component unmounts, so a player leaving needs no dispose call of your own. Call room.leave() in the provider’s cleanup so the room sees the departure without waiting out the reconnection grace window, 30 seconds by default and set with reconnectGraceMs in irtio/room.ts.

Write batching. useFrame runs on the animation frame, which is the frame the client batches owned writes to, with a 50 ms hard cap set by writeIntervalMs on joinRoom. Writing one field in several useFrame callbacks in one frame costs one update on the wire.

React without react-three-fiber. The split is the same. Mount from room.clients, then run your own loop in an effect: requestAnimationFrame, read room.render, write to a ref or a canvas context, and cancel the frame in the cleanup.

Next steps