cf.completefrontendCode editorOpen lab
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
    Update nested data safelyCopy every level you change and identify which references are new or reused.
  • 02
    Choose safe array and clone toolsUse non-mutating array methods, structuredClone, and freeze with their real limits.
  • 03
    Design 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.

Real-life analogyImmutable updates are document versions

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.

The lesson rule

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

FOUNDATION

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

Real-life analogyA photocopy of the first notebook page

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.

Safe one-level update
Change a top-level fieldPop 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 nested shortcut
The inner address is sharedPop 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

When 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

INTERACTIVE

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

Nested update lab
Correct nested updatePop 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);
ResultCorrect nested spread
Original city after updateParis
Next cityTokyo
statenew
usernew
addressnew
tagsreused
Try it yourself

Correct: the original city stays Tokyo only in the next state, and the changed path has new references. The unchanged tags array is reused.

Run the three update styles against a fresh state object. Watch both values and identity checks.

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 a nested update
Step 0 of 6Ready
Your turn: follow the blue line

Step through the correct nested spread. Predict which references become new and which are reused.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  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);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the update to replay

Changing the setting starts a fresh recorded run.

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.

Non-mutating array methods

SORT

Arrays 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?”

Array method sorter
Array method samplePop 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 operationtoSorted
Original array[3,1,2]
Returned value[1,2,3]
Try it yourself

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

Every button starts from [3, 1, 2], applies one operation, and shows whether the original changed.

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.

Mutating or non-mutating?
  • 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

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.

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

structuredClone and Object.freeze

INTERACTIVE

Messaging & 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.

Deep copy and freeze lab
Clone and freeze samplePop 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);}
Observed outputclone
Original nameAda
Clone nameLin
Try it yourself

structuredClone made a separate nested user.

All outputs come from real JavaScript functions in this lesson module.
Copying and locking tools compared
ToolHow deep?What it is good forMain warning
Spread on objects or arraysOne levelSmall, explicit updates where you know the pathNested references are still shared unless you copy those levels too
structuredClone(value)Deep for supported dataSnapshots of plain data, Maps, Sets, Dates, typed arrays, cyclesFunctions and many live host objects throw DataCloneError; custom methods are not preserved
Object.freeze(obj)ShallowCatching accidental writes to one object in strict codeNested objects and Map or Set entries can still change
Recursive deepFreezeDeep through objects you visitConfiguration objects and tests that should fail loudlyYou must handle cycles and special objects carefully in production helpers

Immutable wrappers for collections

LAB

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

Frozen Map vs read-only wrapper
Collection wrapperPop 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);
Map resultread-only view
Frozen Map themelight
Wrapper themelight
Wrapper has setfalse
Try it yourself
Result from one real run

Freezing the Map did not stop set, but the wrapper exposes no set method to callers.

Use wrappers or persistent data libraries when collection writes must be impossible through an API.

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.

Shared mutable state

INTERACTIVE

Shared mutable state is the spooky version of references: two owners have the same object, one owner mutates it, and the other owner sees a change it did not ask for. UI code runs into this constantly when two components receive the same array or object.

Shared mutable state lab
Two readers, one 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 reader seesreal output
Surprising right sidedraft,review,published
Fixed old right sidedraft,review
Fixed next left sidedraft,review,published
Try it yourself
One reference vs one new array

The first pair shares an array, so a push surprises the other reader. The fixed pair creates a new array for the changed owner.

This is the same reference problem that makes UI state bugs feel spooky.

Notice that the fixed version does not deep-clone everything. It only makes a new array for the changed owner. The old reader keeps the old array and remains stable. This is why immutable updates pair so well with pure functions: the function can return the next state without editing its input.

Structural sharing in practice

CONCEPT
Real-life analogyStructural sharing is a new book edition

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

Where you'll use this

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.” const protects 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; set still 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 EXERCISES
Exercise 1 · Warm-upPredict the shallow nested update

Run the code mentally. Type the two console 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 update

    Write the immutable update. Then add identity 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 array item without mutating

      What does the original array print after creating next?

      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 · PracticeProve frozen Map entries still change

        Predict the printed theme.

        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 · ChallengeDesign a read-only view

          Create a tiny API that lets readers inspect a Set without giving them write methods.

          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 7Which copy does object spread make?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does the shallow nested update 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 operation leaves the original array unchanged?

              Choose an answer to see the explanation.

            4. Question 4 of 7What does structuredClone do with a function property?

              Choose an answer to see the explanation.

            5. Question 5 of 7What does the frozen nested object 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

            • 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.
            • structuredClone deep-copies supported data and throws for functions.
            • Object.freeze is 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.

            CompleteFrontend Clear concepts. Working examples.