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.
- 01Change nested data safelyCopy every object on the way to the value you change, and tell which objects are new and which are shared.
- 02Pick the right array, copy, and lock toolsUse array methods that return copies,
structuredClone, andObject.freeze, and know what each one can't do. - 03Avoid surprises from shared dataStop one part of a program from changing data another part is reading, with read-only views and structural sharing.
Make a new version instead of changing the old one
To mutate a value means to change it in place. For example, user.city = "Tokyo" changes the user object itself.
Immutability is a habit: you don't change data that other code may still be reading. When something needs to change, you make a new version with the change in it. The old version stays exactly as it was.
JavaScript doesn't force this habit. Normal objects and arrays can always be changed. But JavaScript gives you tools to copy, and tools to lock. This lesson shows each tool and its limits.
Two earlier lessons lead here:
- In Objects & references, you learned that a variable holds a reference to an object: a pointer to it, not a copy of it. Two variables can point at the same object.
- In Pure functions & side effects, you learned that functions are easier to test when they don't change their inputs.
Immutability puts these together. You copy the parts you change, return the new value, and leave the old value alone.
Five teammates are reading one document. If you write edits on the original while they read, the text changes under their eyes. If you save a new version instead, the readers of the old version see no change. Anyone who wants your edits opens the new version.
- In real life: A shared document people are reading
- In JavaScript: The current object or array
- In real life: Writing edits on the original
- In JavaScript: Changing the object through a reference others also hold
- In real life: Saving a new version with your edits
- In JavaScript: Returning a new copy with the change
- In real life: Readers finish the old version in peace
- In JavaScript: Other code keeps using the old object safely
- Copy every object on the way to the value you change.
- Keep the parts you didn't change. Don't copy them.
- Know which tools copy only the top level.
Spread copies only the top level
FOUNDATIONThe three dots ... are called spread. They copy things into a new object or array:
{ ...state }makes a new object. It copies every property thatstateholds itself (its own properties that show up when you list them).[...items]makes a new array with the same items in the same order.
Both copies are shallow. Shallow means only the top level is new. If a property holds another object, the copy gets a reference to that same object, not a new one. An object inside another object is called a nested object.
You photocopy the first page of a notebook. Then you clip the same pages from the original behind the photocopy. Now there seem to be two notebooks. But both share the pages behind the first page. If you write on one of those pages, both notebooks show the writing.
- 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 that both copies point at
- In real life: Writing on a clipped page
- In JavaScript: Changing a nested object through either copy
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); // TokyoIn the first example, line 2 copies user and sets a new city on the copy. city is a top-level property, so the copy has its own. user.city stays Paris, and next.city is Tokyo.
In the second example, line 2 copies only the top level of state. The user property in next points at the same user object as in state. Line 3 changes the city through next, so line 4 prints Tokyo for state too.
{ ...state } makes a new outer object, but state.user and next.user are still one object. So changing the city through next changes it for state too.The fix: when the value you change is nested, spread every object on the way to it. The code gets longer, but each spread clearly says "this level is new in the next version."
Try three ways to change a nested city
INTERACTIVEThis is the main experiment of the lesson. The state object has these parts:
- a
userobject, - an
addressobject insideuser, which holds thecity, - a
tagsarray, next touser.
Each button changes the city from Paris to Tokyo in a different way. The result shows two things: the city in the old and the new state, and which objects are new.
The checks use ===. For objects, === asks "is this the very same object?", not "do they look the same?". "shared" means the old and new state use that same object. "new" means the new state has its own copy.
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 old state still says Paris, and only the new state says Tokyo. state, user, and address are new. The tags array didn't change, so it is shared.
The correct update makes three new objects, one for each level on the way to city:
state !== next: a new top-level object.state.user !== next.user: a newuser.state.user.address !== next.user.address: a newaddress.
It keeps state.tags === next.tags, because the tags didn't change. This is not a mistake. Sharing the parts that didn't change is called structural sharing.
next gets its own new user and address, so only next says Tokyo. The tags array did not change, so both states use the same one.Now step through the code one line at a time. Choose the correct update or the shallow mistake.
Step through the correct update. Before each step, guess which objects will be new and which will be shared.
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);Array methods that change the array, and ones that copy
SORTSome array methods change the array you call them on. Others leave it alone and return a new array. The names are easy to mix up. After each line, ask: "What does the original array print now?"
const items = ["tea", "bread", "rice"];
const reversed = items.toReversed();
const replaced = items.with(1, "coffee");
const inserted = items.toSpliced(1, 1, "milk", "eggs");
console.log(items.join(","));
console.log(reversed.join(","));
console.log(replaced.join(","));
console.log(inserted.join(","));Line by line:
- Line 1 makes a shopping list,
items. - Line 2:
toReversed()returns a copy in reverse order. - Line 3:
with(1, "coffee")returns a copy where the item at index 1 iscoffee. - Line 4:
toSpliced(1, 1, "milk", "eggs")returns a copy. At index 1 it removes one item and putsmilkandeggsthere.
The four logs show that items still prints tea,bread,rice. Each new array has its one change. An index below zero counts from the end, so items.with(-1, "fruit") replaces the last item.
toReversed() builds a new array in reverse order. The old array, items, still holds tea,bread,rice.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 method returned [1,2,3].
These methods change the original array:
pushadds an item at the end.sortputs the items in order.reverseflips the order.spliceremoves or inserts items.
These return a new array and leave the original alone: concat, spread, map, filter, toSorted, toReversed, toSpliced, and with. The last four were added to JavaScript in 2023 (ES2023). Node 22 and current browsers have them. Older browsers may not.
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)
Put each operation in a group. If it changes the original, choose Mutates. If it returns a new value, choose Returns copy. If it hands out only reading methods, choose Read-only view.
Deep copies with structuredClone, locks with Object.freeze
INTERACTIVEMessaging & structured cloning showed structuredClone(value). It makes a deep copy: a copy of every level, nested objects too. It works on data:
- plain objects and arrays,
- Maps, Sets, and Dates,
- typed arrays (arrays of raw numbers),
- objects that point back at themselves.
It can't copy functions. A function is code, not data. If the value holds a function, structuredClone throws an error called DataCloneError.
Freezing, sealing & immutability showed the object locks. Object.freeze(obj) locks the properties of obj: you can't add, remove, or change them. But it locks only the top level. A nested object can still change unless you freeze it too.
What happens when code writes to a locked property depends on the mode:
- In strict mode (code that starts with
"use strict", and all modules and classes), JavaScript throws aTypeError. - In sloppy mode (an old-style script without
"use strict"), the write is ignored without any error.
const settings = { theme: "dark" };
settings.theme = "light";
console.log(settings.theme);
const sealed = Object.seal({ theme: "dark" });
sealed.theme = "light";
sealed.mode = "quiet";
console.log(sealed.theme);
console.log("mode" in sealed);
const closed = Object.preventExtensions({ theme: "dark" });
closed.theme = "light";
closed.mode = "quiet";
console.log(closed.theme);
console.log("mode" in closed);This code runs in sloppy mode, so blocked writes are ignored instead of throwing. Line by line:
- Lines 1 to 3: the write works and prints
light.constonly stops you from givingsettingsa different value. The object itself can still change. - Lines 5 to 9:
Object.seallets you change a property that already exists, sothemebecomeslight. It blocks new properties, so"mode" in sealedprintsfalse. - Lines 11 to 15:
Object.preventExtensionsalso blocks new properties. The existingthemestill changes tolight.
"use strict";
const frozen = Object.freeze({ theme: "dark" });
let threw = false;
try {
frozen.theme = "light";
} catch (error) {
threw = error instanceof TypeError;
}
console.log(frozen.theme);
console.log(threw);Line 1 turns on strict mode. Line 5 tries to change the frozen theme, which throws. Lines 6 and 7 catch the error and check that it is a TypeError. The code prints dark and true: the value didn't change, and JavaScript reported a TypeError.
Use a lock to catch writes you didn't mean to make. When you do want a change, make a new copy with the change.
"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 user object, so changing the copy left the original as Ada.
This table compares the tools for copying and locking. Read one row per tool.
| Tool | How many levels does it reach? | When is it useful? | What to watch for |
|---|---|---|---|
| Spread on objects or arrays | Only the top level. | Small, clear changes when you know the way to the value. | Nested objects are still shared unless you spread them too. |
structuredClone(value) | Every level of data. | Full copies of plain data, Maps, Sets, Dates, typed arrays, and objects that point at themselves. | Functions and many browser objects throw DataCloneError. Class methods are not kept. |
Object.freeze(obj) | Only the top level. | Catching writes you didn't mean to one object, in strict mode. | Nested objects and Map or Set entries can still change. |
Your own deepFreeze that calls itself on each level | Every object it visits. | Settings objects and tests that should fail loudly on a write. | A real version must handle objects that point at themselves, and special objects like Map. |
This table compares the four kinds of objects, from the most open to the most locked.
| Tool | What can still change? | When is it useful? | What to watch for |
|---|---|---|---|
| Ordinary object | You can change, add, and remove properties. | Everyday objects you plan to change. | const doesn't lock the object. It only stops you giving the variable a new value. |
Object.preventExtensions | You can change and remove properties, but not add new ones. | Stopping an object from growing. | It locks only the top level. In sloppy mode, blocked writes fail without an error. |
Object.seal | You can change properties, but not add or remove them. | Keeping the same set of properties. | It locks only the top level. The values can still change. |
Object.freeze | Nothing: you can't change, add, or remove properties. | Catching writes you didn't mean, in strict mode. | It locks only the top level. In sloppy mode, blocked writes fail without an error. |
Read-only views for Maps and Sets
LABMaps and Sets are objects, but they don't keep their entries in normal properties. They keep them in hidden storage inside the object. Object.freeze locks only normal properties, so it doesn't lock that storage. After freezing a Map, frozenMap.set("theme", "light") still changes it.
A better way is a read-only view, also called a wrapper. It is a small object that keeps the real Map inside and offers only reading methods, such as get and has. It has no set method, so code that gets the view has no way to write.
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 didn't stop set, so the theme changed to light. The view has no set method at all.
The view doesn't lock the Map itself. Code that still has the real Map can change it. The view works because you choose what to give to other code: you give out the view, not the Map.
Some libraries help in bigger apps:
- Immer lets you write code that looks like a change, and it builds the new copy for you.
- Other libraries offer collections that can never change.
Records and Tuples were a proposal for objects and arrays that can never change. They are not part of JavaScript today.
Share the parts that didn't change
CONCEPTA publisher fixes one chapter of a book. It doesn't rewrite the whole book. It makes a new edition with a new cover, puts in the fixed chapter, and keeps every other chapter as it was.
- In real life: A chapter with corrections
- In JavaScript: The nested object you changed
- In real life: Chapters taken as they are from the old edition
- In JavaScript: References kept for the parts you didn't change
- In real life: A new cover and table of contents
- In JavaScript: A new top-level state object
Structural sharing is why immutable updates are fast enough for real apps. You don't deep-copy everything on each change. You make new objects only on the way from the top to the changed value. Everything else is shared.
It also makes changes quick to find:
- If
oldState.user !== nextState.user, something insideuserchanged. - If
oldState.tags === nextState.tags, the tags are the same array, so they didn't change.
You'll use immutable updates in many places:
- functions that take the old state and return the next state (called reducers),
- undo buttons, which need the old versions,
- pages that show a change at once, before the server answers,
- saved copies of data, form drafts, and settings,
- tests that compare the state before and after.
Common misconceptions
PITFALLS- "I used const, so the object can't change."
constonly stops you from giving the variable a different value. The object's properties can still change. - "Spread made a deep copy." Spread copies only the top level. Nested objects are still shared.
- "Object.freeze locks every level."
Object.freezelocks only the top level. Nested objects need their own freeze. - "A frozen Map can't change." A Map keeps its entries in hidden storage, so
setstill works on a frozen Map. - "Immutable means copy everything." Structural sharing keeps the parts that didn't change, on purpose.
- "structuredClone is always the best way to update." It is good for saving a full copy of data. But it can't copy functions, and it copies far more than a small spread update needs.
Practice exercises
5 EXERCISESRead the code and work out what it prints. Type the two printed 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);It prints Tokyo and then true. The spread makes next a new object, but user is the same object in both states. So the city changes for state too.
Write the update without changing state. Then add === 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 all new. The spreads also copy the other properties, such as name, into the new objects.
After line 2 makes next, what does the original array print?
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.
Work out which theme it prints.
"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"));It prints light. set still changes the Map's hidden entries, even though the Map object is frozen.
Write a small object that lets other code look inside a Set but gives it no way to change the Set.
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 keeps the real Set inside, but it has no add, delete, or clear. Code with the real Set can still change it, so give other code only the view.
Check your understanding
7 QUESTIONSQuestion 1 of 7What kind of copy does object spread
{ ...obj }make?Choose an answer to see the explanation.
Question 2 of 7After a spread copy and a nested change, what does this code 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 method leaves the original array unchanged?
Choose an answer to see the explanation.
Question 4 of 7What does
structuredClonedo when the value has a function in it?Choose an answer to see the explanation.
Question 5 of 7After freezing the outer object, what does this code 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
- An immutable update returns a new version and leaves the old one as it was.
- Spread copies only the top level. Copy every object on the way to the value you change.
- Use array methods that return a copy when other code still needs the original array.
structuredClonecopies every level of data, and throws for functions.Object.freezelocks only the top level. Writes to it throw in strict mode. A frozen Map can still change its entries.- Structural sharing keeps the parts that didn't change, on purpose.
Immutability means each change makes a new value, and the old value stays exactly as it was.
Up next: Currying & partial application.