React (JavaScript/TypeScript SDK)

You can use the @colyseus/sdk directly in your React applications. We also provide a separate package, @colyseus/react, with custom hooks for room connections and state subscriptions, designed for React’s rendering model.

See an example project using @colyseus/react and React Three Fiber: r3f-lobby-car-prototype

Installation

npm install @colyseus/react

Hooks

useRoom(callback, deps?)

Manages the lifecycle of a Colyseus room connection. Handles connecting, disconnecting on unmount, and reconnecting when dependencies change. Works correctly with React StrictMode.

import { Client } from "@colyseus/sdk";
import { useRoom } from "@colyseus/react";
 
const client = new Client("ws://localhost:2567");
 
function Game() {
  const { room, error, isConnecting } = useRoom(
    () => client.joinOrCreate("game_room"),
  );
 
  if (isConnecting) return <p>Connecting...</p>;
  if (error) return <p>Error: {error.message}</p>;
 
  return <GameView room={room} />;
}

The first argument is a callback that returns a Promise<Room>. Any Colyseus matchmaking method works (joinOrCreate, join, create, joinById, consumeSeatReservation).

Reconnecting on dependency changes:

const { room } = useRoom(
  () => client.joinOrCreate("game_room", { level }),
  [level],
);

When level changes the previous room is left and a new connection is established.

Conditional connection:

Pass a falsy value to skip connecting until a condition is met:

const { room } = useRoom(
  isReady ? () => client.joinOrCreate("game_room") : null,
  [isReady],
);

useRoomState(room, selector?)

Subscribes to Colyseus room state changes and returns immutable plain-object snapshots. Unchanged portions of the state tree keep referential equality between renders, so React components only re-render when the data they use actually changes.

import { useRoom, useRoomState } from "@colyseus/react";
 
function Game() {
  const { room } = useRoom(() => client.joinOrCreate("game_room"));
  const state = useRoomState(room);
 
  if (!state) return <p>Waiting for state...</p>;
 
  return <p>Players: {state.players.size}</p>;
}

Using a selector to subscribe to a subset of the state:

const players = useRoomState(room, (state) => state.players);

Only components that read players will re-render when the players map changes.

useLobbyRoom(callback, deps?)

This hook is designed to connect to a Colyseus Lobby Room and manage the list of available rooms. If you’re connecting to a regular game room, use useRoom() instead.

Connects to a Lobby Room and provides a live-updating list of available rooms. The list is automatically maintained as rooms are created, updated, and removed.

import { Client } from "@colyseus/sdk";
import { useLobbyRoom } from "@colyseus/react";
 
const client = new Client("ws://localhost:2567");
 
function Lobby() {
  const { rooms, error, isConnecting } = useLobbyRoom(
    () => client.joinOrCreate("lobby"),
  );
 
  if (isConnecting) return <p>Connecting...</p>;
  if (error) return <p>Error: {error.message}</p>;
 
  return (
    <ul>
      {rooms.map((room) => (
        <li key={room.roomId}>
          {room.name}: {room.clients}/{room.maxClients} players
        </li>
      ))}
    </ul>
  );
}

Return value:

FieldTypeDescription
roomsRoomAvailable<Metadata>[]Live list of available rooms
roomRoom | undefinedThe underlying lobby room connection
errorError | undefinedConnection error, if any
isConnectingbooleantrue while connecting to the lobby

useQueueRoom(connect, consume, deps?)

This hook is designed to manage the full lifecycle of a Queue Room. If you’re connecting to a regular game room, use useRoom() instead.

Manages the full lifecycle of a Queue Room: connecting to the queue room, tracking group size, and receiving a seat reservation. The hook then confirms and consumes the seat to join the match room. Cleans up both rooms on unmount.

import { Client } from "@colyseus/sdk";
import { useQueueRoom } from "@colyseus/react";
 
const client = new Client("ws://localhost:2567");
 
function Matchmaking() {
  const { room, clients, isWaiting, error } = useQueueRoom(
    () => client.joinOrCreate("queue", { rank: 1200 }),
    (reservation) => client.consumeSeatReservation(reservation),
  );
 
  if (error) return <p>Error: {error.message}</p>;
  if (room) return <GameScreen room={room} />;
  if (isWaiting) return <p>Waiting for match... {clients} players in group</p>;
  return <p>Connecting...</p>;
}

The first argument connects to the queue room. The second argument is called with the SeatReservation once a match is found. Use client.consumeSeatReservation() to join the match room.

Return value:

FieldTypeDescription
roomRoom | undefinedThe match room, once the seat has been consumed
queueRoom | undefinedThe queue room while waiting (undefined after match is joined)
clientsnumberNumber of clients in the current matchmaking group
seatSeatReservation | undefinedThe seat reservation, once received
errorError | undefinedConnection or matchmaking error
isWaitingbooleantrue while connected to the queue and waiting for a match

Netcode Hooks (Colyseus 0.18+)

React bindings for the @colyseus/sdk Predict tools: client prediction, reconciliation and remote smoothing. See Client Prediction & Reconciliation for the underlying concepts.

⚠️

The one rule: structure renders through snapshots, motion renders through predict reads. useRoomState re-renders at patch rate with raw synced values, so rendering positions from it gives you jittery patch-rate motion. Predicted/smoothed values change every frame and must stay out of the React render cycle. Read them with predict.value(instance, field) inside your frame loop (R3F useFrame, or the usePredictLoop callback) and write into refs.

Every hook below exists in two forms: standalone, taking the room as its first argument (as documented here), and bound (returned by createRoomContext(), no room argument needed). Pass Predict options once via the context:

const {
  RoomProvider, useRoom, useRoomState,
  usePredict, useInput, usePredictLoop, useReconciler,
  useAttachAll, useEventChannel, useEntityInstance, useSessionEntity,
} = createRoomContext({ predict: { mode: "lerp", delay: 100 } });

usePredict(room, options?)

Returns the room’s shared Predict instance. Predict.get() constructs a fresh instance per call, so the hook memoizes one per room, reference-counts consumers, and disposes the instance when the last consumer unmounts. Works correctly with React StrictMode.

const predict = usePredict(room);
 
useFrame(() => {
  meshRef.current.position.x = predict.value(entity, "x");
});

useInput(room, options?)

Returns the room’s InputHandle (room.input(): idempotent, stable per room). Stage fields on input.data, then input.send() once per fixed step inside the usePredictLoop callback.

usePredictLoop(room, onSteps, options?)

Owns the per-frame driver: calls predict.tick(now) once per frame and hands you the number of fixed steps due. Stage + send exactly that many inputs inside the callback (“send before you read”):

const input = useInput(room);
const jump = useInputBuffer();
 
usePredictLoop(room, (steps) => {
  for (let i = 0; i < steps; i++) {
    input.data.moveX = readAxis();     // held state: sample live
    input.data.jump = jump.consume();  // tap: true on at most one step
    input.send();
  }
});

By default it runs its own requestAnimationFrame loop. With { external: true } it returns a drive(now?) function to call from a frame loop you already have:

// In React Three Fiber, drive at an early priority so ticks precede reads:
const drive = usePredictLoop(room, onSteps, { external: true });
useFrame(() => drive(), -1);

useReconciler(room, select, options)

Active prediction for the entity you control. This hook is predict.reconciler() with the lifecycle handled. The hook waits for the entity to spawn (select: (state, room) => instance) and recreates the controller if the server replaces the instance. On unmount, it disposes the controller. options.input defaults to the room’s input handle.

const me = useReconciler(
  room,
  (state, room) => state.players.get(room.sessionId),
  { step: (ctx, p, cmd) => applyInput(p, cmd, ctx.dt), smoothing: 15, snap: 5 },
);

useAttachAll(room, key, config, deps?)

Passive smoothing for a root-state collection: predict.attachAll() as an effect, detached on unmount. Attach angular fields in a separate call:

useAttachAll(room, "players", { mode: "lerp", fields: ["x", "y", "z"] });
useAttachAll(room, "players", { mode: "lerp", fields: ["heading"], angle: true });

useEventChannel(room, options)

Optimistic event channel. This hook is predict.defineEvent() with teardown on unmount, plus reactivity: whenever the channel settles (predict / confirm / reject / unpredicted), the calling component re-renders. Flag-shaped derives then work in plain render code:

const pickups = useEventChannel(room, {
  confirmOn: { collection: "items", field: "alive", equals: false },
});
 
const hidden = !item.alive || pickups?.has(id);   // reactive

The returned object is the real SDK channel. Hand it to ctx.predict inside a reconciler step, or call channel.predict(payload) from UI code.

useEntityInstance(room, select) / useSessionEntity(room, collectionKey)

Select a decoded schema instance (what the Predict APIs key off), re-rendering only when its identity changes, never on field updates. useSessionEntity is the common case: your own entity by sessionId:

const me = useSessionEntity(room, "players");
const flag = useEntityInstance(room, (state) => state.ctf.flag);

useInputBuffer()

The “buffer, then consume” input recipe as a hook. Input is sampled once per fixed step, not per render frame. A tap on a 0-step frame must not be lost, and a tap spanning a multi-step frame must not fire twice. Call press() in the React event handler, consume() inside the send loop so the tap lands on exactly one fixed step:

const jump = useInputBuffer();
 
<button onPointerDown={jump.press}>Jump</button>
 
// inside the usePredictLoop callback:
input.data.jump = jump.consume();

getSchemaInstance(snapshot)

Not a hook: bridges useRoomState snapshots back to their decoded instance, for predict.value() reads on entities you render from snapshot lists:

const source = getSchemaInstance(playerSnapshot);
 
useFrame(() => {
  meshRef.current.position.x = predict.value(source, "x");
});

For a complete working example (R3F + prediction + remote lerp), see r3f-lobby-car-prototype.

Contexts

createRoomContext()

Creates a set of hooks and a RoomProvider component that share a single room connection across React reconciler boundaries (e.g. DOM + React Three Fiber). The room is stored in a closure-scoped external store rather than React Context, so the hooks work in any reconciler tree that imports them.

import { Client } from "@colyseus/sdk";
import { createRoomContext } from "@colyseus/react";
 
const client = new Client("ws://localhost:2567");
 
const { RoomProvider, useRoom, useRoomState } = createRoomContext();

Wrap your app with RoomProvider:

function App() {
  return (
    <RoomProvider connect={() => client.joinOrCreate("game_room")}>
      <UI />
      <Canvas>
        <GameScene />
      </Canvas>
    </RoomProvider>
  );
}

RoomProvider accepts a connect callback (same as the standalone useRoom hook) and an optional deps array. Pass a falsy value to connect to defer the connection.

Use the hooks in any component, DOM or R3F:

function UI() {
  const { room, error, isConnecting } = useRoom();
  const players = useRoomState((state) => state.players);
 
  if (isConnecting) return <p>Connecting...</p>;
  if (error) return <p>Error: {error.message}</p>;
 
  return <p>Players: {players?.size}</p>;
}

The returned useRoom() and useRoomState(selector?) work identically to the standalone hooks but don’t require you to pass the room as an argument.

On Colyseus 0.18+, the context also returns room-bound versions of every netcode hook (usePredict, useInput, usePredictLoop, useReconciler, useAttachAll, useEventChannel, useEntityInstance, useSessionEntity), with shared Predict options passed once via createRoomContext({ predict: { mode: "lerp", delay: 100 } }).

createLobbyContext()

Creates a LobbyProvider and useLobby hook for sharing lobby room data globally across your app. Useful when you need room metadata available persistently alongside an active game room, not just on a lobby screen. Like createRoomContext, it uses a closure-scoped external store so the hook works across reconciler boundaries.

import { Client } from "@colyseus/sdk";
import { createLobbyContext, createRoomContext } from "@colyseus/react";
 
const client = new Client("ws://localhost:2567");
 
const { LobbyProvider, useLobby } = createLobbyContext<MyMetadata>();
const { RoomProvider, useRoom, useRoomState } = createRoomContext();

Wrap your app with LobbyProvider (can nest with RoomProvider):

function App() {
  return (
    <LobbyProvider connect={() => client.joinOrCreate("lobby")}>
      <RoomProvider connect={() => client.joinOrCreate("game_room")}>
        <UI />
        <Canvas>
          <GameScene />
        </Canvas>
      </RoomProvider>
    </LobbyProvider>
  );
}

LobbyProvider accepts a connect callback (same as useLobbyRoom) and an optional deps array. The lobby connection persists independently of the game room.

Access lobby data from any component, even deep inside the game:

function RoomBrowser() {
  const { rooms, error, isConnecting } = useLobby();
 
  if (isConnecting) return <p>Loading rooms...</p>;
  if (error) return <p>Error: {error.message}</p>;
 
  return (
    <ul>
      {rooms.map((room) => (
        <li key={room.roomId}>
          {room.metadata.displayName}: {room.clients}/{room.maxClients}
        </li>
      ))}
    </ul>
  );
}

The returned useLobby() hook provides the same fields as useLobbyRoom (rooms, room, error, isConnecting).


Using @colyseus/sdk directly

If you prefer manual control over the room lifecycle without the @colyseus/react package, you can use @colyseus/sdk directly with useEffect:

RoomComponent.tsx
import { useEffect, useState, useRef } from "react";
import { Client, Room } from "@colyseus/sdk";
 
const client = new Client("http://localhost:2567");
 
function RoomComponent () {
    const roomRef = useRef<Room>();
    const [ players, setPlayers ] = useState([]);
 
    useEffect(() => {
        const req = client.joinOrCreate("my_room", {});
 
        req.then((room) => {
            roomRef.current = room;
            room.onStateChange((state) => setPlayers(state.players.toJSON()));
        });
 
        return () => { req.then((room) => room.leave()); };
    }, []);
 
    return (
        <div>
            {players.map((player) => (
                <div key={player.id}>{player.name}</div>
            ))}
        </div>
    );
}

The @colyseus/react hooks handle StrictMode, cleanup, and reconnection automatically. Use direct SDK access only when you need full control over the connection lifecycle.

Next Steps