Community JavaScript Snippet
How I Stopped Mutating Nested State in React
The bug we shipped: a deeply nested form state was being updated in place, and React refused to re-render. Here is the 25-line `setIn` helper I now reach for instead of Immer.
How I Stopped Mutating Nested State in React
The bug we shipped: a deeply nested form state was being updated in place, and React refused to re-render. Here is the 25-line `setIn` helper I now reach for instead of Immer.
By @kwamehenderson
February 23, 2026
·
Updated May 18, 2026
1,120 views
17
4.4 (14)
The bug is that setState(prev) calls Object.is(prev, next) to decide whether to re-render. If we mutated state.user.profile.address.city in place, every level of that tree still points to the same object, so React bails out and the screen stays stale. The fix is uglier than it needs to be: spread the root, the user, the profile, AND the address, only then assign the new city. Notice the reference-identity diffs at the bottom: only the path I touched gets new references, every other subtree is shared. That preserves the React.memo short-circuit for siblings, which is the reason we bother with immutability in the first place.
Three behaviours matter and the 25 lines pin all three down. First, every ancestor of the path is a new reference, so React sees the change at every level it might be memoizing. Second, every untouched subtree is identity-equal to the original (flags === state.flags), so sibling memoization keeps working. Third, the recursion creates intermediate nodes when a path is missing, which means I can setIn({}, 'a.b.c', 42) without pre-allocating. The numeric-segment branch is the practical bit I always need because forms have list-of-X fields and path: 'items.3.qty' is the most natural way to reference them. People often reach for Immer here, but for a flat setIn the 25-line cost beats adding a runtime dependency.
This is the shape I actually ship. The reducer body is four lines because all the work lives in setIn, and dispatch payloads stay tiny: { type: 'SET_FIELD', path: 'user.profile.address.city', value }. Sibling preservation is what makes this scale: items[1] === initial.items[1] after we touched items[0], so a memoized <LineItem item={items[1]} /> does not re-render. The shim at the top means the snippet runs in this playground; in a real app useReducer comes from React directly. Once you have this, you stop reaching for Immer for 90% of the cases that motivate it; the remaining 10% are deep merges, which setIn does not try to do.
