Immutable updates: change the data, not the copy
- Explain that spread copies one level, and write nested updates that copy every level on the path to the change
- Choose copying array methods (
map,filter,toSorted,toSpliced,with— ES2023) over mutating ones (push,splice,sort,reverse) - Choose between a targeted update,
structuredCloneandObject.freeze, knowing whatstructuredClonecan't clone and thatfreezeis shallow - Predict how mutation breaks React: the
Object.isbail-out skips the update, and StrictMode's double reducer call doubles apush
Read
A copy made with spread is one level deep
FlagA variable holding an object or array holds a reference — an arrow pointing at the object, not the object itself. Copying the variable copies the arrow:
const a = { name: "Ada" };
const b = a; // same object, two arrows
b.name = "Grace";
a.name; // "Grace"
Spread ({ ...obj }, [...arr]) makes a new outer object and copies each property into it.
But a property that holds an object holds an arrow too, and the arrow is what gets copied:
const state = { user: { name: "Ada" }, count: 1 };
const next = { ...state, count: 2 };
next === state; // false — a new outer object
next.user === state.user; // true — the SAME inner object
next.user.name = "Grace";
state.user.name; // "Grace" — the "copy" changed the original
That's what shallow means: one level copied, everything below it shared.
To change something nested without touching the original, copy every level on the path from the top down to the thing you change, and nothing else:
const next = {
...state, // new outer object
user: { ...state.user, name: "Grace" }, // new user object with the change
};
state.user.name; // "Ada" — untouched
Everything you didn't change is still shared between state and next — and that's fine, as
long as nobody mutates it. "Immutable updates" means exactly this: never change an existing
object; build a new one with the change, reusing the parts that stayed the same.
Nested updates, line by line
FlagA board holds columns, each column holds cards. Every update below returns a new board and leaves the old one exactly as it was:
type Card = { id: string; title: string; done: boolean; tags: string[] };
type Board = { name: string; columns: { id: string; cards: Card[] }[] };
// Toggle one card: copy the board, the columns array, the one column, its cards array, the card.
function toggleCard(board: Board, columnId: string, cardId: string): Board {
return {
...board,
columns: board.columns.map((col) =>
col.id !== columnId
? col // untouched columns are reused as-is
: {
...col,
cards: col.cards.map((card) =>
card.id === cardId ? { ...card, done: !card.done } : card,
),
},
),
};
}
// Add a tag: the tags array is one level deeper, so it gets its own copy.
const tagged = { ...card, tags: [...card.tags, "urgent"] };
// Insert, remove, replace, move — the ES2023 copying methods:
const inserted = cards.toSpliced(2, 0, newCard); // insert at index 2
const removed = cards.filter((c) => c.id !== cardId); // remove by id
const replaced = cards.with(index, updatedCard); // replace at an index
const moved = cards.toSpliced(from, 1).toSpliced(to, 0, cards[from]); // move one card
const sorted = cards.toSorted((a, b) => a.title.localeCompare(b.title));
What to notice:
mapis the workhorse. It returns a new array, and the callback decides per item: return the same object (unchanged) or a new one (changed). Unchanged items keep their identity — which is what lets memoized children and selectors skip work.- Every level on the path gets a spread: board →
columns→ the column →cards→ the card. Miss one and you are mutating something the old board still points to. { ...card, tags: [...card.tags, "urgent"] }, not{ ...card }thenpush. Spreading the card copies the arrow to itstagsarray; pushing into it changes the original card's tags too.with(index, value)throws aRangeErrorfor an index outside the array (negative indexes count from the end, likeat).arr[index] = valueon a copy would silently grow the array instead.
When the nesting gets deeper than this, the spreads become hard to read, and that's the usual
argument for flattening state (store cards by id, columns as lists of ids) or for a helper library.
React's docs point to Immer, which lets you write draft.columns[0].cards[2].done = true against a
draft and produces the copies for you.
structuredClone and Object.freeze — what they really do
FlagstructuredClone: a real deep copy, with limits
structuredClone(value) (in every major browser since 2022, Node 17+) copies every level. Unlike
the old JSON.parse(JSON.stringify(x)) trick, it keeps Date, Map, Set, RegExp, typed
arrays and undefined values, and handles circular references. It isn't magic, though. MDN's list
of what the structured clone algorithm can't do:
- Functions throw. An object holding a callback fails with a
DataCloneError— the whole call, not just that property. - DOM nodes throw, the same way.
- The prototype chain is not copied. A class instance comes back as a plain object with its
fields and none of its methods:
structuredClone(new Money(250))is{ cents: 250 }, andcopy instanceof Moneyisfalse. - Getters, setters and property descriptors are not copied: a getter is read once and stored
as a plain value; a read-only property comes back writable. Class private fields (
#x) are dropped.
const copy = structuredClone({ at: new Date(0), price: new Money(250) });
copy.at instanceof Date; // true
copy.price instanceof Money; // false — methods gone
structuredClone({ onSave: () => {} }); // DataCloneError
Deep copying is usually the wrong fix for a state update
It's tempting to structuredClone(state), mutate the clone freely, and return it. It works, but
it gives every object in the state a new identity, changed or not. Every memo child sees new
props, every selector returns a "new" value, every effect that depends on a slice re-runs. The
targeted update — copy the changed path, reuse the rest — is what lets everything else skip work.
Use structuredClone when you need an independent snapshot (a form draft, an undo history entry,
data handed to code you don't trust), not as the default way to update.
Object.freeze is shallow, and returns the same object
Object.freeze(obj) makes obj's own properties read-only and stops new ones being added. It
freezes that one object: nested objects stay writable. And it returns the same object, not a
frozen copy.
const config = Object.freeze({ retries: 3, api: { timeout: 1000 } });
config.retries = 5; // TypeError in strict mode; silently ignored otherwise
config.api.timeout = 9999; // works — api itself isn't frozen
ES modules and class bodies are always strict, so in modern code the first line throws. Array
methods throw on a frozen array in any mode: Object.freeze([3, 1, 2]).sort() is a TypeError,
while .toSorted() works.
Quiz
const next = { ...state, count: 2 }; next.user.name = "Grace"; — what is state.user.name now?
In plain JavaScript, setTags(tags.push("new")). What does the component render next?
A component renders {users.sort(byName).map(…)}, where users is a prop. What's wrong?
structuredClone(form), where form holds a Date, a Map, and an onSave callback. What happens?
A teammate writes every reducer case as const next = structuredClone(state); /* mutate next */ return next;. It's correct. What does it cost?
In an ES module: const config = Object.freeze({ api: { retries: 3 } }); config.api.retries = 5; — what happens?
items.push(item); setItems(items); — the list on screen doesn't change. Why?
A reducer does state.todos.push(action.todo); return { ...state };. In development every Add shows the todo twice; in production, once. Why?
Fill in the blank
Toggle one todo without mutating: a new array, a new object for the changed todo, and the same objects for the rest.
Flagconst todos = state.todos.((t) => t.id === id ? { t, done: !t.done } : t, );
Get the users in name order without reordering the array you were given (ES2023).
Flagconst byName = users.((a, b) => a.name.localeCompare(b.name));
Replace the row at index with updatedRow, returning a new array (ES2023).
const nextRows = rows.(index, updatedRow);
Remove the todo with this id, returning a new array.
const nextTodos = todos.((t) => t.id !== id);
Take an independent deep copy of a saved form for a draft — one that keeps Date and Map values as they are.
const draft = (savedForm);
Predict the output
const state = { user: { name: "Ada" }, count: 1 };
const next = { ...state, count: 2 };
next.user.name = "Grace";
console.log(state.user.name, state.count);const original = [3, 1, 2]; const sorted = original.sort(); console.log(original, sorted === original);
class Money {
constructor(cents) { this.cents = cents; }
format() { return "$" + (this.cents / 100).toFixed(2); }
}
const copy = structuredClone({ at: new Date(0), price: new Money(250) });
console.log(copy.at instanceof Date, copy.price instanceof Money, typeof copy.price.format);function reducer(state, action) {
state.todos.push(action.text); // mutates the current state
return { ...state };
}
function Todos() {
const [state, dispatch] = useReducer(reducer, { todos: [] });
return (
<>
<button onClick={() => dispatch({ text: "milk" })}>Add</button>
<p>{state.todos.join(", ")}</p>
</>
);
}
// Rendered inside <StrictMode>, in development (React 19). The user clicks Add once.Challenge
Take the mutations out of a todo reducer
Flagtodos.ts holds the reducer behind a todo list, and a selector for its A–Z view. Every case
returns something that looks new — { ...state }, a copied array, a copied todo — so it reads
as immutable. It isn't: four lines change objects the previous state still holds, and React (an
undo history, a memo child, an effect) relies on the previous state staying exactly as it was.
Open the preview and click Add. It runs under <StrictMode>, which in development calls a
reducer twice and keeps one result — harmless for a pure reducer, and a visible duplicate for one
that pushes into shared state. Toggle a checkbox, click tag or switch to A–Z and watch for
similar surprises.
The tests keep the state they pass to the reducer and check it afterwards: the previous state must look exactly as it did before the action.
removed is already correct — use it as the pattern.
Your task
Edit todos.ts so the tests pass:
addedreturns the list with the new todo, and the previous state still has 2 todostoggledflipsdonein the new state only, and the todo it didn't touch is still the same objecttaggedadds the tag in the new state only — the previous todo'stagsare unchangedremoveddrops the todo without changing the previous statetoggledwith an unknown id returns the very samestateobjectselectByTitlereturns the todos A–Z and leavesstate.todosin its original order