Immutability
Learn how to update JavaScript data by copying instead of mutating: nested spreads, non-mutating arrays, structuredClone, Object.freeze, collection wrappers, shared state, and structural sharing.
- 01Update nested data safelyCopy every level you change and identify which references are new or reused.
- 02Choose safe array and clone toolsUse non-mutating array methods, structuredClone, and freeze with their real limits.
- 03Design around shared stateAvoid surprising component updates with immutable wrappers and structural sharing.
Copies instead of surprise changes
Immutability is the habit of treating data like a saved version. When something changes, you make a new version instead of editing the version other code might still be reading. JavaScript does not force this style for ordinary objects and arrays, but it gives you enough tools to use it deliberately.
You already learned in Objects & references that objects and arrays are shared by reference. You also learned in Pure functions & side effects that functions are easier to test when they do not edit their inputs. Immutability is the practical bridge: copy the changed path, return the next value, and let the old value remain true.
Imagine five teammates reading one document. If you scribble edits onto the original while they read, everyone sees a moving target. If you save a new version, readers of the old version are not surprised, and people who want the update can open the new version.
- In real life: A shared document people are reading
- In JavaScript: The current object or array
- In real life: Scribbling directly on the original
- In JavaScript: Mutating through an existing reference
- In real life: Saving a new version with edits
- In JavaScript: Returning a copied next state
- In real life: Readers finishing the old version
- In JavaScript: Other code safely using the old reference
Where the analogy stops: Real document systems may duplicate whole files. JavaScript immutable updates can reuse unchanged nested objects, so a new version does not always mean a full deep copy.
Copy every level on the path you change. Reuse branches you did not change. Know when a tool is only shallow.
Spread updates copy one level
FOUNDATIONObject spread, { ...state }, creates a new object and copies the source object’s own enumerable properties. Array spread, [...items], creates a new array with the same elements. Both are shallow: if a copied property is itself an object, the new wrapper points at the same nested object.
Photocopying only the first page makes two notebooks look separate. But if both keep the same pages behind it, editing one of those pages changes what both notebooks show. That is how a shallow spread can fool you.
- In real life: A new photocopy of the first page
- In JavaScript: A new top-level object from spread
- In real life: The same pages clipped behind it
- In JavaScript: Nested objects copied as references
- In real life: Editing a clipped page
- In JavaScript: Changing a nested object through either wrapper
Where the analogy stops: Notebook pages are physical. JavaScript references are invisible, so use === to prove what is shared.
const user = { name: "Ada", city: "Paris" };
const next = { ...user, city: "Tokyo" };
console.log(user.city); // Paris
console.log(next.city); // Tokyoconst state = { user: { address: { city: "Paris" } } };
const next = { ...state };
next.user.address.city = "Tokyo";
console.log(state.user.address.city); // TokyoWhen the changed value is nested, spread every object on the route to that value. That may look noisy at first, but it is wonderfully explicit: each spread says, “this level is part of the new version.”
The nested update lab
INTERACTIVEHere is the heart of the lesson. The state shape has a top-level object, a nested user, a deeper address, and a separate tags array. Try the three update styles, then read the identity checks like a reference map.
const state = { user: { name: "Ada", address: { city: "Paris" } }, tags: ["admin", "writer"],}; const next = { ...state, user: { ...state.user, address: { ...state.user.address, city: "Tokyo", }, },}; console.log(state.user.address.city);console.log(next.user.address.city);console.log(state === next);console.log(state.user === next.user);console.log(state.user.address === next.user.address);console.log(state.tags === next.tags);Correct: the original city stays Tokyo only in the next state, and the changed path has new references. The unchanged tags array is reused.
The correct update makes state !== next, state.user !== next.user, and state.user.address !== next.user.address. It keeps state.tags === next.tags because tags did not change. That last equality is not a bug; it is intentional structural sharing.
Step through the correct nested spread. Predict which references become new and which are reused.
script
user: { name: "Ada", address: { city: "Paris" } }, tags: ["admin", "writer"],}; const next = { ...state, user: { ...state.user, address: { ...state.user.address, city: "Tokyo", }, },}; console.log(state.user.address.city);console.log(next.user.address.city);console.log(state === next);console.log(state.user === next.user);console.log(state.user.address === next.user.address);console.log(state.tags === next.tags);Non-mutating array methods
SORTArrays have both families: methods that edit the original and methods that return a new array. The names are easy to mix up, so always ask, “what does the original array print after this line?”
const numbers = [3, 1, 2];const pushed = numbers.push(4);console.log(numbers.join(",")); const sorted = numbers.toSorted();console.log(numbers.join(","));console.log(sorted.join(",")); const mapped = numbers.map((n) => n * 10);console.log(numbers.join(","));console.log(mapped.join(","));After toSorted, the original array is [3,1,2] and the returned value is [1,2,3].
Mutating classics include push, sort, reverse, and splice. Copying options include concat, spread, map, filter, and the ES2023 copying methods toSorted, toReversed, toSpliced, and with. Node 22 and modern browsers support those ES2023 methods; older runtimes may need a fallback.
items.push(value)items.sort()items.reverse()items.splice(1, 1)[...items, value]items.map(fn)items.filter(fn)items.with(0, value)readonlyMap(map)
Place each operation where it belongs. If it edits the original, mark it mutating; if it returns a new value, mark it copying; if it hides writers, mark it as a read-only view.
structuredClone and Object.freeze
INTERACTIVEMessaging & structured cloning introduced the structured clone algorithm. structuredClone(value) deep copies supported data: plain objects, arrays, Maps, Sets, Dates, typed arrays, and cycles. It throws DataCloneError for functions because functions are behavior, not cloneable data.
Freezing, sealing & immutability covered the object locks. The crucial reminder here is that Object.freeze is shallow. In strict code, writing a frozen top-level property throws a TypeError; in sloppy classic scripts it often fails silently. But a nested object still changes unless you freeze it too.
"use strict";const original = { user: { name: "Ada" }, tags: ["js"] };const clone = structuredClone(original);clone.user.name = "Lin";console.log(original.user.name);console.log(clone.user.name); const frozen = Object.freeze({ user: { name: "Ada" } });frozen.user.name = "Grace";console.log(frozen.user.name);try { frozen.user = { name: "Lin" };} catch (error) { console.log(error instanceof TypeError);}structuredClone made a separate nested user.
| Tool | How deep? | What it is good for | Main warning |
|---|---|---|---|
| Spread on objects or arrays | One level | Small, explicit updates where you know the path | Nested references are still shared unless you copy those levels too |
structuredClone(value) | Deep for supported data | Snapshots of plain data, Maps, Sets, Dates, typed arrays, cycles | Functions and many live host objects throw DataCloneError; custom methods are not preserved |
Object.freeze(obj) | Shallow | Catching accidental writes to one object in strict code | Nested objects and Map or Set entries can still change |
Recursive deepFreeze | Deep through objects you visit | Configuration objects and tests that should fail loudly | You must handle cycles and special objects carefully in production helpers |
Immutable wrappers for collections
LABMaps and Sets are objects, but their entries live in internal storage. Freezing a Map object does not freeze that internal storage, so frozenMap.set("theme", "light") still changes the Map. The safer API is often a wrapper that only exposes read methods.
function readonlyMap(map) { return { get: (key) => map.get(key), has: (key) => map.has(key), entries: () => map.entries(), };} const frozenMap = Object.freeze(new Map([["theme", "dark"]]));frozenMap.set("theme", "light");console.log(frozenMap.get("theme")); const view = readonlyMap(frozenMap);console.log(view.get("theme"));console.log("set" in view);Freezing the Map did not stop set, but the wrapper exposes no set method to callers.
A wrapper is not a force field around the original collection. Code that still has the original Map can mutate it. The wrapper works by controlling what you hand to readers. For larger apps, libraries such as Immer can produce immutable updates from draft-looking code; true persistent collection libraries go further. Records and Tuples are a proposal for deeply immutable value types, but they are not part of JavaScript today.
Structural sharing in practice
CONCEPTA publisher does not reprint every chapter when only one chapter changed. It creates a new edition, swaps in the corrected chapter, and reuses the unchanged chapters. Immutable JavaScript updates do the same thing with references.
- In real life: A chapter with corrections
- In JavaScript: The changed nested object
- In real life: Unchanged chapters reused from the old edition
- In JavaScript: References reused for unchanged branches
- In real life: A new cover and table of contents
- In JavaScript: A new top-level state object
Where the analogy stops: Books cannot literally share paper between editions. JavaScript objects can share references, so unchanged branches can be reused without copying their contents.
Structural sharing is the reason immutable updates can be fast enough for real applications. You do not need a deep copy for every update. You need new references from the root to the changed value, plus reused references for unchanged branches. That also makes comparison cheap: if oldState.user !== nextState.user, something under user changed; if oldState.tags === nextState.tags, tags were reused.
Use immutable updates for reducer functions, undo stacks, optimistic UI updates, cached data snapshots, form drafts, settings objects, and tests that compare before and after state.
Common misconceptions
PITFALLS- “I used const, so it is immutable.”
constprotects the variable binding, not the object’s properties. - “Spread made a deep copy.” Spread copies one level of own enumerable properties.
- “Object.freeze protects the whole tree.” Freeze is shallow unless you recursively freeze nested objects.
- “A frozen Map cannot change.” Map entries are internal;
setstill works on a frozen Map. - “Immutable means copy everything.” Structural sharing deliberately reuses unchanged references.
- “structuredClone is always the best update tool.” It is useful for data snapshots, but it cannot clone functions and may be more work than a focused spread update.
Practice exercises
5 EXERCISESRun the code mentally. Type the two console lines in order.
const state = { user: { address: { city: "Paris" } } };
const next = { ...state };
next.user.address.city = "Tokyo";
console.log(state.user.address.city);
console.log(state.user === next.user);const state = { user: { address: { city: "Paris" } } };
const next = { ...state };
next.user.address.city = "Tokyo";
console.log(state.user.address.city);
console.log(state.user === next.user);The program prints Tokyo and true. The shallow spread makes next new, but user is the same object in both states.
Write the immutable update. Then add identity checks for each level.
const state = { user: { name: "Ada", address: { city: "Paris" } } };
// Create next with city "Tokyo" without changing state.const next = {
...state,
user: {
...state.user,
address: { ...state.user.address, city: "Tokyo" },
},
};The top object, user, and address are new. Properties not on the changed path, such as name, are copied through.
What does the original array print after creating next?
const items = ["draft", "review"];
const next = [...items, "published"];
console.log(items.join(","));
console.log(next.join(","));const items = ["draft", "review"];
const next = [...items, "published"];
console.log(items.join(","));
console.log(next.join(","));next is a new array with published at the end. The original items still prints draft,review.
Predict the printed theme.
"use strict";
const frozen = Object.freeze(new Map([["theme", "dark"]]));
frozen.set("theme", "light");
console.log(frozen.get("theme"));"use strict";
const frozen = Object.freeze(new Map([["theme", "dark"]]));
frozen.set("theme", "light");
console.log(frozen.get("theme"));The output is light. The set method still mutates the Map's internal entries even though the Map object is frozen.
Create a tiny API that lets readers inspect a Set without giving them write methods.
function readonlySet(set) {
// return an object that can read but cannot add/delete
}function readonlySet(set) {
return {
has: (value) => set.has(value),
values: () => set.values(),
get size() {
return set.size;
},
};
}The view closes over the real Set but does not expose add, delete, or clear. Code with the original Set can still mutate it, so hand out only the view to readers.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which copy does object spread make?
Choose an answer to see the explanation.
Question 2 of 7What does the shallow nested update print?
Read the code, then predictconst state = { user: { address: { city: "Paris" } } }; const next = { ...state }; next.user.address.city = "Tokyo"; console.log(state.user.address.city);Choose an answer to see the explanation.
Question 3 of 7Which array operation leaves the original array unchanged?
Choose an answer to see the explanation.
Question 4 of 7What does
structuredClonedo with a function property?Choose an answer to see the explanation.
Question 5 of 7What does the frozen nested object print?
Read the code, then predict"use strict"; const frozen = Object.freeze({ user: { name: "Ada" } }); frozen.user.name = "Grace"; console.log(frozen.user.name);Choose an answer to see the explanation.
Question 6 of 7Why does
Object.freeze(new Map()).set(key, value)still work?Choose an answer to see the explanation.
Question 7 of 7What is structural sharing?
Choose an answer to see the explanation.
Key takeaways
- Immutable updates return a new version instead of changing the old one.
- Spread is shallow: copy every level on the path to the changed value.
- Use copying array methods when callers still need the original array.
structuredClonedeep-copies supported data and throws for functions.Object.freezeis shallow, strict writes throw, and frozen Maps can still change entries.- Structural sharing reuses unchanged references on purpose.
Immutability means each update creates a trustworthy next value while the previous value remains unchanged.
Up next: Currying & partial application.