completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Change nested data safelyCopy every object on the way to the value you change, and tell which objects are new and which are shared.
  • 02
    Pick the right array, copy, and lock toolsUse array methods that return copies, structuredClone, and Object.freeze, and know what each one can't do.
  • 03
    Avoid 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.

Real-life analogyA shared document and its saved versions

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
The rule for this lesson
  • 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

FOUNDATION

The three dots ... are called spread. They copy things into a new object or array:

  • { ...state } makes a new object. It copies every property that state holds 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.

Real-life analogyA photocopy of the first notebook page

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
Safe: change a top-level property
Change a top-level propertyPop out in the code editor (opens in a new tab)JavaScript
const user = { name: "Ada", city: "Paris" };
const next = { ...user, city: "Tokyo" };
console.log(user.city); // Paris
console.log(next.city); // Tokyo
Unsafe: change a nested property
Both states share one addressPop out in the code editor (opens in a new tab)JavaScript
const state = { user: { address: { city: "Paris" } } };
const next = { ...state };
next.user.address.city = "Tokyo";
console.log(state.user.address.city); // Tokyo

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

Two tags, state.user and next.user, both point at the same user cart, which holds the address card. Because there is only one address, both say Tokyo.stateusernextuseraddressuserboth say Tokyo
{ ...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

INTERACTIVE

This is the main experiment of the lesson. The state object has these parts:

  • a user object,
  • an address object inside user, which holds the city,
  • a tags array, next to user.

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.

Three ways to change the city
Spread every level on the way to cityPop out in the code editor (opens in a new tab)JavaScript
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);
ResultSpread every level
Old state's cityParis
New state's cityTokyo
statenew
usernew
addressnew
tagsshared
Try it yourself

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.

Each button starts from a fresh state and changes the city to Tokyo. Compare both cities, and see which objects are new.

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 new user.
  • state.user.address !== next.user.address: a new address.

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.

Two carts, state and next. Each has its own user card. Their tags cards both point at one shared array, admin and writer. Under state the city is Paris; under next the city is Tokyo.["admin","writer"]same arrayusertagsstatecity: "Paris"tagsusernextcity: "Tokyo"
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 city change
Step 0 of 6Ready
Your turn: follow the blue line

Step through the correct update. Before each step, guess which objects will be new and which will be shared.

Running in
  1. script
Next: line 1
  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);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the code to step through

Changing this starts a new run from line 1.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Array methods that change the array, and ones that copy

SORT

Some 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?"

Methods that return a changed copyPop out in the code editor (opens in a new tab)JavaScript
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:

  1. Line 1 makes a shopping list, items.
  2. Line 2: toReversed() returns a copy in reverse order.
  3. Line 3: with(1, "coffee") returns a copy where the item at index 1 is coffee.
  4. Line 4: toSpliced(1, 1, "milk", "eggs") returns a copy. At index 1 it removes one item and puts milk and eggs there.

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.

A box called items holds tea, bread, rice. An arrow marked toReversed() leads to a new box called reversed that holds rice, bread, tea. The items box is unchanged.tea,bread,riceitemsunchangedtoReversed()rice,bread,teareversednew array
toReversed() builds a new array in reverse order. The old array, items, still holds tea,bread,rice.
Which array methods change the array?
push changes the array; toSorted and map copy itPop out in the code editor (opens in a new tab)JavaScript
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 the methodtoSorted
Original array[3,1,2]
Returned value[1,2,3]
Try it yourself

After toSorted, the original array is [3,1,2] and the method returned [1,2,3].

Each button starts from [3, 1, 2], runs one method, and shows whether the original array changed.

These methods change the original array:

  • push adds an item at the end.
  • sort puts the items in order.
  • reverse flips the order.
  • splice removes 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.

Does it change the array?
  • 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)
Try it yourself
0 of 9 correct

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.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Deep copies with structuredClone, locks with Object.freeze

INTERACTIVE

Messaging & 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 a TypeError.
  • In sloppy mode (an old-style script without "use strict"), the write is ignored without any error.
const, Object.seal, and Object.preventExtensionsPop out in the code editor (opens in a new tab)JavaScript
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:

  1. Lines 1 to 3: the write works and prints light. const only stops you from giving settings a different value. The object itself can still change.
  2. Lines 5 to 9: Object.seal lets you change a property that already exists, so theme becomes light. It blocks new properties, so "mode" in sealed prints false.
  3. Lines 11 to 15: Object.preventExtensions also blocks new properties. The existing theme still changes to light.
Writing to a frozen object in strict modePop out in the code editor (opens in a new tab)JavaScript
"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.

Copy and freeze experiment
Copy with structuredClone, lock with Object.freezePop out in the code editor (opens in a new tab)JavaScript
"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);}
What it printedclone
Original nameAda
Copy's nameLin
Try it yourself

structuredClone made a separate user object, so changing the copy left the original as Ada.

Every value here comes from running the real JavaScript in this lesson.

This table compares the tools for copying and locking. Read one row per tool.

Copying and locking tools compared
ToolHow many levels does it reach?When is it useful?What to watch for
Spread on objects or arraysOnly 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 levelEvery 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.

What each kind of lock allows
ToolWhat can still change?When is it useful?What to watch for
Ordinary objectYou 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.preventExtensionsYou 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.sealYou 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.freezeNothing: 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

LAB

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

A frozen Map and a read-only view
A view with only reading methodsPop out in the code editor (opens in a new tab)JavaScript
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);
What it printedread-only view
Frozen Map's themelight
Theme read through the viewlight
Does the view have set?false
Try it yourself
Result of one real run

Freezing the Map didn't stop set, so the theme changed to light. The view has no set method at all.

When other code must not write to a Map, give it a read-only view instead of the Map.

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.

Two owners, one array

INTERACTIVE

Shared mutable state means two parts of a program hold a reference to the same object or array, and one of them changes it. The other part then sees a change it never asked for. This happens a lot on web pages, when two parts of the page get the same array.

Two tags, left.items and right.items, both point at one array card. After push, the card says draft, review, published, so right sees the new item too.leftitemsrightitemspush("published")draft,review,publishedright sees it too
left.items and right.items are one array. A push through left adds published for right too.
Shared array experiment
A push through left, then a fix with a new arrayPop out in the code editor (opens in a new tab)JavaScript
const left = { items: ["draft", "review"] };const right = { items: left.items }; left.items.push("published");console.log(right.items.join(",")); const saferLeft = { items: ["draft", "review"] };const saferRight = { items: saferLeft.items };const nextLeft = { ...saferLeft, items: [...saferLeft.items, "published"] };console.log(saferRight.items.join(","));console.log(nextLeft.items.join(","));
What each owner seesreal output
right, after the pushdraft,review,published
saferRight, after the fixdraft,review
nextLeft, after the fixdraft,review,published
Try it yourself
One shared array, then one new array

In the first half, left and right share one array, so the push shows up for right too. In the fixed half, only nextLeft gets a new array, so saferRight keeps the old list.

Two parts of a page that share one array can change each other's data by accident.

The fix doesn't deep-copy everything. On line 9, only nextLeft gets a new array with the extra item. saferRight keeps the old array, so it still prints draft,review.

This is why immutable updates fit well with pure functions. The function returns the new state and doesn't change its input.

Share the parts that didn't change

CONCEPT
Real-life analogyA new edition of a book

A 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 inside user changed.
  • If oldState.tags === nextState.tags, the tags are the same array, so they didn't change.
Where you'll use this

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." const only 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.freeze locks 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 set still 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 EXERCISES
Exercise 1 · Warm-upPredict the shallow nested change

Read the code and work out what it prints. Type the two printed lines in order.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFix a nested city change

    Write the update without changing state. Then add === checks for each level.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const state = { user: { name: "Ada", address: { city: "Paris" } } };
    // Create next with city "Tokyo" without changing state.
      Exercise 3 · PracticeAdd an item without changing the array

      After line 2 makes next, what does the original array print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const items = ["draft", "review"];
      const next = [...items, "published"];
      console.log(items.join(","));
      console.log(next.join(","));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeShow that a frozen Map still changes

        Work out which theme it prints.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        "use strict";
        const frozen = Object.freeze(new Map([["theme", "dark"]]));
        frozen.set("theme", "light");
        console.log(frozen.get("theme"));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeBuild a read-only view of a Set

          Write a small object that lets other code look inside a Set but gives it no way to change the Set.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          function readonlySet(set) {
            // return an object that can read but cannot add/delete
          }

            Check your understanding

            7 QUESTIONS
            Immutability quiz · 7 questionsScore: first tries count
            1. Question 1 of 7What kind of copy does object spread { ...obj } make?

              Choose an answer to see the explanation.

            2. Question 2 of 7After a spread copy and a nested change, what does this code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            3. Question 3 of 7Which array method leaves the original array unchanged?

              Choose an answer to see the explanation.

            4. Question 4 of 7What does structuredClone do when the value has a function in it?

              Choose an answer to see the explanation.

            5. Question 5 of 7After freezing the outer object, what does this code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              "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.

            6. Question 6 of 7Why does Object.freeze(new Map()).set(key, value) still work?

              Choose an answer to see the explanation.

            7. 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.
            • structuredClone copies every level of data, and throws for functions.
            • Object.freeze locks 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.

            CompleteFrontend Clear concepts. Working examples.