JavaScript & TypeScript Fundamentals
JavaScript & TypeScript Fundamentals

Immutable updates: change the data, not the copy

Full · ~35 min
After this unit you can
  • 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, structuredClone and Object.freeze, knowing what structuredClone can't clone and that freeze is shallow
  • Predict how mutation breaks React: the Object.is bail-out skips the update, and StrictMode's double reducer call doubles a push

Read

Simple
1 / 2

A copy made with spread is one level deep

Flag

A 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.

Medium
1 / 2

Nested updates, line by line

Flag

A 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:

  • map is 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 } then push. Spreading the card copies the arrow to its tags array; pushing into it changes the original card's tags too.
  • with(index, value) throws a RangeError for an index outside the array (negative indexes count from the end, like at). arr[index] = value on 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.

Complex
1 / 2

structuredClone and Object.freeze — what they really do

Flag

structuredClone: 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 }, and copy instanceof Money is false.
  • 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?

Flag

In plain JavaScript, setTags(tags.push("new")). What does the component render next?

Flag

A component renders {users.sort(byName).map(…)}, where users is a prop. What's wrong?

Flag

structuredClone(form), where form holds a Date, a Map, and an onSave callback. What happens?

Flag

A teammate writes every reducer case as const next = structuredClone(state); /* mutate next */ return next;. It's correct. What does it cost?

Flag

In an ES module: const config = Object.freeze({ api: { retries: 3 } }); config.api.retries = 5; — what happens?

Flag

items.push(item); setItems(items); — the list on screen doesn't change. Why?

Flag

A reducer does state.todos.push(action.todo); return { ...state };. In development every Add shows the todo twice; in production, once. Why?

Flag

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.

Flag
const 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).

Flag
const byName = users.((a, b) => a.name.localeCompare(b.name));

Replace the row at index with updatedRow, returning a new array (ES2023).

Flag
const nextRows = rows.(index, updatedRow);

Remove the todo with this id, returning a new array.

Flag
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.

Flag
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);
Flag
const original = [3, 1, 2];
const sorted = original.sort();
console.log(original, sorted === original);
Flag
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);
Flag
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.
Flag

Challenge

Take the mutations out of a todo reducer

Flag

todos.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:

  • added returns the list with the new todo, and the previous state still has 2 todos
  • toggled flips done in the new state only, and the todo it didn't touch is still the same object
  • tagged adds the tag in the new state only — the previous todo's tags are unchanged
  • removed drops the todo without changing the previous state
  • toggled with an unknown id returns the very same state object
  • selectByTitle returns the todos A–Z and leaves state.todos in its original order

Loading the in-browser editor…

Further reading