State Management
Zocket’s state management flows from server-side Immer mutations through JSON patches to client-side subscriptions.
Server: Immer Mutations
Section titled “Server: Immer Mutations”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.
Patch Generation
Section titled “Patch Generation”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.
Broadcast
Section titled “Broadcast”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" } } ]}Client: State Store
Section titled “Client: State Store”Each actor handle has a StateStore that maintains a local copy of the state:
- Snapshot — received on first subscription, replaces local state entirely
- Patches — applied incrementally using
structuredClone+ patch logic - Notification — all subscribers are called after each update
// Subscribe to state changesconst unsub = room.state.subscribe((state) => { console.log("Messages:", state.messages);});
// Read current stateconst current = room.state.getSnapshot();React: Selectors
Section titled “React: Selectors”Use useActorState with a selector to subscribe to specific parts of state:
// Only re-renders when `phase` changesconst phase = useActorState(room, (s) => s.phase);
// Only re-renders when the number of players changesconst 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.
Caching
Section titled “Caching”The selector result is cached — if the raw state reference hasn’t changed, the previous selected value is reused. This prevents unnecessary re-renders.
Best Practices
Section titled “Best Practices”Keep State Flat
Section titled “Keep State Flat”// Good — flat, easy to patchstate: 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 efficientlystate: z.object({ game: z.object({ round: z.object({ phase: z.string(), players: z.array(/* ... */), }) })})Use Selectors
Section titled “Use Selectors”Don’t subscribe to the full state when you only need one field:
// Good — minimal re-rendersconst phase = useActorState(room, (s) => s.phase);
// Avoid — re-renders on every state changeconst state = useActorState(room);const phase = state?.phase;Avoid New References in Selectors
Section titled “Avoid New References in Selectors”// Avoid — creates a new array every timeconst sorted = useActorState(room, (s) => [...s.players].sort((a, b) => b.score - a.score));
// Better — select the data, sort in useMemoconst players = useActorState(room, (s) => s.players);const sorted = useMemo( () => players ? [...players].sort((a, b) => b.score - a.score) : [], [players]);