boardweaver/react ships no store for client state, on purpose. Selection, hover, open panels, tentative arrangements, and undo/redo are ordinary React state — the platform owns only what you physically cannot build (the network, optimistic reconcile, the pending queue).
Use useState, useReducer, and context. For structural updates and undo stacks, use boardweaver/immer.
boardweaver/immerA version-locked re-export of Immer with patch support already enabled:
import { produce, produceWithPatches, applyPatches, type Patch } from "boardweaver/immer";
const next = produce(plan, (draft) => {
draft.placements[plotId] = pieceId;
});
Do not add your own Immer dependency — bare imports fail the build, and the platform's copy is the one the runtime is built against.
1. Guard shared ids. Client state holds pieceId / spaceId values, and a snapshot can delete the referenced entity at any moment. Every lookup is | undefined:
const tile = game.piece(selectedId);
if (!tile) {
// the piece went away — clear your selection rather than crashing
}
2. Never mirror shared state. Don't copy match.state into React state "so it's easier to edit". The optimistic state is already the merged truth; a second copy will drift the moment a snapshot lands. Keep only what the server doesn't know about: what you've selected, not what is.
The common pattern: let a player arrange pieces tentatively, undo/redo freely, then commit the whole arrangement as one click.
This is a game-side pattern, not a platform feature. The shape:
match.click(...) and seal the history at that point — everything before the seal is no longer undoable, because it's now shared state that only the server can reverse.const actionId = match.click(plantAction(plotId), {
onResult: (result) => {
if (result.status === "failed") {
plan.unseal(actionId); // the move didn't happen — restore history
setNote(result.failure.error.message);
}
},
});
plan.seal(actionId); // barrier: don't undo past a live click
Sealing before the result arrives is deliberate — the click is in flight and optimistically applied, so the player must not be able to undo behind it. onResult is what reverses that decision if the server says no.
The useUndoable helper in the game template is the canonical implementation, built entirely on this surface. Read it rather than reinventing the barrier logic.
Derived hooks are memoized on (match.state, viewingPlayerId), and match.state is reference-stable across snapshots for unchanged subtrees. To convert that into actual skipped renders:
const Tile = memo(function Tile({ piece, onPick }: { piece: PieceData; onPick: (id: string) => void }) {
return <img src={useImage(piece.image)} onClick={() => onPick(piece.pieceId)} />;
});
Two things make this work:
<Tile piece={pieces[i]} /> lets memo compare a stable reference. <Tile pieceId={id} /> forces the child to look it up, defeating the guarantee.match.click is already stable for the match's lifetime; wrap your own handlers in useCallback.Every component calling useMatch() re-renders on every match change, so push useMatch() as far up as is practical and pass data down — don't call it in every leaf.
useAvailableActions() and useButtons() already run your real rules on optimistic state.match.state already includes them.match.pending and match.online; don't re-send.