Have more questions? Join our

Client state and undo

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/immer

A 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.

The two rules

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.

Arrange-then-commit undo

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:

  1. Keep the tentative plan in React state, with an Immer patch history for undo/redo.
  2. When the player commits, 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.
  3. If the click fails, unseal: the move never happened, so the player keeps their history.
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.

Memoization

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:

  • Pass the object, not the id. <Tile piece={pieces[i]} /> lets memo compare a stable reference. <Tile pieceId={id} /> forces the child to look it up, defeating the guarantee.
  • Keep callbacks stable. 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.

What not to build

  • A client rules engine. useAvailableActions() and useButtons() already run your real rules on optimistic state.
  • Your own optimistic layer. Clicks are applied locally before they're sent; match.state already includes them.
  • A retry queue. The runtime owns pending clicks and rollback. Render match.pending and match.online; don't re-send.