immer state updates

why

mutation outside a named transition boundary breaks reference equality: published objects change identity in place, memoized selectors and effect dependencies miss the change, and stale renders become silent, unreproducible bugs. Immer confined to one reducer boundary keeps nested updates readable while preserving the invariant that a value's reference reflects its contents.

rule

  • use Immer at a named local-state reducer or state transition boundary when nested immutable updates would obscure the transition.
  • inputs and outputs stay ordinary immutable domain values; no caller receives a draft.
  • Immer makes one next value from one prior value. it does not make a component the authority for server data, remove validation, or make mutations to an already-published object safe.
  • mutating a published object and reusing its reference means consumers cannot distinguish old state from new: UI doesn't re-render, useMemo caches stale results, useEffect dependencies silently miss the transition.

decide before adopting it

questionselected direction
is the state temporary and locally owned?use a reducer or state owner; keep server facts in their query cache.
is the update a genuine state transition?make it a named action, then implement that action with produce if it clarifies nested changes.
does an undo, audit, or sync protocol need change records?make patches an explicit protocol owned by that feature; do not expose them incidentally.
does the transition cross a service, storage, or API boundary?send an explicit command or validated value, never a draft.

keep the recipe narrow

put the produce call beside the reducer action. the action name describes the domain transition; the recipe describes only how it creates the next immutable value. unchanged branches retain identity.

good and bad

bad

function toggleStep(stepId: string) {
const step = workflow.steps.find((step) => step.id === stepId);
if (step) step.complete = true;
setWorkflow(workflow); // reference unchanged, UI doesn't re-render
}
// parent component memoizes expensive computation
const completedCount = useMemo(
() => workflow.steps.filter((s) => s.complete).length,
[workflow], // dependency never triggers because workflow reference didn't change
);
// selector component expects the state to change
useEffect(() => {
if (workflow.steps.some((s) => s.complete)) {
onWorkflowUpdate(); // silent miss: the reference didn't change so effect doesn't fire
}
}, [workflow]);

mutation happens but the reference never changes: React's reference check fails, memoized computations cache stale results, effect dependencies miss the transition.

good

function completeWorkflowStep(workflow: Workflow, stepId: string): Workflow {
return produce(workflow, (draft) => {
const step = draft.steps.find((candidate) => candidate.id === stepId);
if (step) step.complete = true;
});
}
// usage
setWorkflow(completeWorkflowStep(workflow, stepId));
// parent component now re-renders reliably
const completedCount = useMemo(
() => workflow.steps.filter((s) => s.complete).length,
[workflow], // dependency fires every time workflow reference changes
);
// useEffect now triggers correctly
useEffect(() => {
if (workflow.steps.some((s) => s.complete)) {
onWorkflowUpdate(); // fires reliably: new reference, new check
}
}, [workflow]);

the named transition returns a new immutable value. mutation syntax stays inside the recipe; reference equality and state derivation work reliably.

verification

  • every recipe starts from owned local state and returns through one named state action.
  • server data is not mirrored solely to make it editable.
  • each consumer receives the next value through the owner, never a draft or a mutated shared reference.

search pages

go to any page