Skip to content

State Management

Zocket’s state management flows from server-side Immer mutations through JSON patches to client-side subscriptions.

Inside method handlers and lifecycle hooks, state is an Immer draft. Mutate it directly:

handler: ({ state, input }) => {
// Direct mutations — Immer tracks all changes
state.messages.push(input.message);
state.count += 1;
state.players[0].score = 100;
}

After the handler returns, Immer generates a list of JSON Patch operations representing the diff.

Zocket converts Immer patches to RFC 6902 JSON Patches:

// Immer patch: { op: "add", path: ["messages", 2], value: { text: "hi" } }
// JSON Patch: { op: "add", path: "/messages/2", value: { text: "hi" } }

Only add, replace, and remove operations are generated.

If any state subscribers are connected, patches are broadcast as state:patch messages:

{
"type": "state:patch",
"actor": "chat",
"actorId": "room-1",
"patches": [
{ "op": "add", "path": "/messages/-", "value": { "text": "hi" } }
]
}

Each actor handle has a StateStore that maintains a local copy of the state:

  1. Snapshot — received on first subscription, replaces local state entirely
  2. Patches — applied incrementally using structuredClone + patch logic
  3. Notification — all subscribers are called after each update
// Subscribe to state changes
const unsub = room.state.subscribe((state) => {
console.log("Messages:", state.messages);
});
// Read current state
const current = room.state.getSnapshot();

Use useActorState with a selector to subscribe to specific parts of state:

// Only re-renders when `phase` changes
const phase = useActorState(room, (s) => s.phase);
// Only re-renders when the number of players changes
const playerCount = useActorState(room, (s) => s.players.length);
// Full state (re-renders on every change)
const state = useActorState(room);

The selector runs locally on every state update. The hook uses useSyncExternalStore for correct concurrent mode behavior.

The selector result is cached — if the raw state reference hasn’t changed, the previous selected value is reused. This prevents unnecessary re-renders.

// Good — flat, easy to patch
state: z.object({
players: z.array(PlayerSchema).default([]),
phase: z.enum(["lobby", "playing"]).default("lobby"),
round: z.number().default(0),
})
// Avoid — deeply nested, harder to select efficiently
state: z.object({
game: z.object({
round: z.object({
phase: z.string(),
players: z.array(/* ... */),
})
})
})

Don’t subscribe to the full state when you only need one field:

// Good — minimal re-renders
const phase = useActorState(room, (s) => s.phase);
// Avoid — re-renders on every state change
const state = useActorState(room);
const phase = state?.phase;
// Avoid — creates a new array every time
const sorted = useActorState(room, (s) =>
[...s.players].sort((a, b) => b.score - a.score)
);
// Better — select the data, sort in useMemo
const players = useActorState(room, (s) => s.players);
const sorted = useMemo(
() => players ? [...players].sort((a, b) => b.score - a.score) : [],
[players]
);