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.

JavaScript
Frontend
3 snippets
react
hooks
immutability
kwamehenderson

By @kwamehenderson

February 23, 2026

·

Updated May 18, 2026

1,120 views

17

4.4 (14)

// Reproducing the exact bug we shipped. State is a deeply nested object; the
// handler reaches into the tree and assigns a property, then calls setState
// with the SAME reference. React.memo and Object.is bail out and nothing renders.

function shallowEqual(a, b) { return Object.is(a, b); }

let renders = 0;
function renderApp(state) {
    renders++;
    return state.user.profile.address.city;
}

// Manual setState that mimics React's bail-out: if the next state is referentially
// equal to the previous, we skip the render. (React does this on every set.)
let state = {
    user: { profile: { name: 'Ada', address: { city: 'London', zip: 'NW1' } } },
};
let rendered = renderApp(state);
console.log('initial render ->', rendered, '| renders =', renders);

// THE BUG. Mutate in place, then "setState(state)".
state.user.profile.address.city = 'Paris';
const nextStateBuggy = state; // same reference!
if (!shallowEqual(state, nextStateBuggy)) rendered = renderApp(nextStateBuggy);
console.log('after mutate-in-place ->', rendered, '| renders =', renders, '(unchanged on purpose)');

// THE FIX. Build a new tree where every ancestor of the changed node is a fresh object.
const nextStateFixed = {
    ...state,
    user: {
        ...state.user,
        profile: {
            ...state.user.profile,
            address: { ...state.user.profile.address, city: 'Berlin' },
        },
    },
};
if (!shallowEqual(state, nextStateFixed)) rendered = renderApp(nextStateFixed);
console.log('after spread-fix ->', rendered, '| renders =', renders);
console.log('reference identity diffs -> root:', state !== nextStateFixed,
    '| address:', state.user.profile.address !== nextStateFixed.user.profile.address);

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.