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)
// 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.
// 25 lines, no dependencies. Accepts dotted-path strings or arrays of keys.
// Returns a fresh object where every ancestor of the path is a new reference
// and every untouched subtree is shared. Numeric segments hit array indices.
function toPath(p) {
return Array.isArray(p) ? p : String(p).split('.').filter(Boolean);
}
function setIn(obj, path, value) {
const segments = toPath(path);
if (segments.length === 0) return value;
function step(node, i) {
const key = segments[i];
const isIndex = /^\d+$/.test(String(key));
const base = node == null ? (isIndex ? [] : {}) : node;
const next = i === segments.length - 1
? value
: step(Array.isArray(base) ? base[Number(key)] : base[key], i + 1);
if (Array.isArray(base)) {
const copy = base.slice();
copy[Number(key)] = next;
return copy;
}
return { ...base, [key]: next };
}
return step(obj, 0);
}
// Demo on the same shape as accordion 1.
const state = {
user: { profile: { name: 'Ada', address: { city: 'London', zip: 'NW1' } } },
flags: ['beta', 'admin'],
};
const next = setIn(state, 'user.profile.address.city', 'Berlin');
console.log('city now ->', next.user.profile.address.city);
console.log('zip shared ->', next.user.profile.address.zip === state.user.profile.address.zip);
console.log('flags shared->', next.flags === state.flags);
console.log('root differs->', next !== state);
// Numeric segments index into arrays.
const withFlag = setIn(state, ['flags', 1], 'super-admin');
console.log('flags[1] now ->', withFlag.flags[1], '| original kept ->', state.flags[1]);
console.log('flags array fresh ->', withFlag.flags !== state.flags);
// Creates intermediate nodes if a segment is missing.
const grown = setIn({}, 'a.b.c', 42);
console.log('grown:', JSON.stringify(grown));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.
// A form reducer that dispatches { type: 'SET_FIELD', path, value }. The
// reducer is one line of business logic plus the helper. No spread chains.
const { useReducer } = (typeof React !== 'undefined' ? React : {
useReducer: (reducer, init) => {
let state = init;
const dispatch = (action) => { state = reducer(state, action); return state; };
return [state, dispatch];
},
});
function toPath(p) { return Array.isArray(p) ? p : String(p).split('.').filter(Boolean); }
function setIn(obj, path, value) {
const segs = toPath(path);
if (!segs.length) return value;
function step(node, i) {
const key = segs[i];
const isIndex = /^\d+$/.test(String(key));
const base = node == null ? (isIndex ? [] : {}) : node;
const next = i === segs.length - 1 ? value
: step(Array.isArray(base) ? base[Number(key)] : base[key], i + 1);
if (Array.isArray(base)) {
const copy = base.slice(); copy[Number(key)] = next; return copy;
}
return { ...base, [key]: next };
}
return step(obj, 0);
}
function formReducer(state, action) {
switch (action.type) {
case 'SET_FIELD': return setIn(state, action.path, action.value);
case 'RESET': return action.next || {};
default: return state;
}
}
const initial = {
user: { profile: { name: '', address: { city: '', zip: '' } } },
items: [{ id: 'a', qty: 1 }, { id: 'b', qty: 2 }],
};
const [, dispatch] = useReducer(formReducer, initial);
let s = dispatch({ type: 'SET_FIELD', path: 'user.profile.name', value: 'Ada' });
s = dispatch({ type: 'SET_FIELD', path: 'user.profile.address.city', value: 'NYC' });
s = dispatch({ type: 'SET_FIELD', path: ['items', 0, 'qty'], value: 5 });
console.log('name set:', s.user.profile.name);
console.log('city set:', s.user.profile.address.city);
console.log('items[0].qty set:', s.items[0].qty);
console.log('items[1] preserved by reference:', s.items[1] === initial.items[1]);
console.log('reducer body length: 4 lines');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.
