Skip to content
JavaScript js functional 4 min read

Immutability

Immutability means treating data as read-only: instead of changing an existing value in place, you produce a new value with the change applied. This single habit eliminates a whole class of bugs caused by shared references being mutated unexpectedly, and it makes change detection, undo/redo, and time-travel debugging trivial. In JavaScript, primitives (strings, numbers, booleans) are already immutable — the discipline matters most for objects and arrays.

Why mutation causes bugs

Objects and arrays are passed by reference. When two parts of your program hold the same reference, a mutation in one place silently affects the other.

const original = { name: "Ada", roles: ["admin"] };
const copy = original;        // same reference, NOT a copy
copy.roles.push("editor");

console.log(original.roles);  // surprise!

Output:

[ 'admin', 'editor' ]

The fix is never to mutate original at all. Build a new object whenever something needs to change.

Updating objects immutably

Use the spread operator (...) to shallow-copy an object and override specific fields. The original is untouched.

const user = { name: "Ada", age: 36, roles: ["admin"] };

const olderUser = { ...user, age: 37 };

console.log(user.age);       // 36
console.log(olderUser.age);  // 37

Output:

36
37

To remove a key immutably, destructure it out and keep the rest:

const { age, ...withoutAge } = user;
console.log(withoutAge); // { name: 'Ada', roles: [ 'admin' ] }

Updating arrays immutably

Reach for the methods that return a new array rather than the ones that mutate. push, pop, splice, sort, and reverse all mutate — avoid them on shared data.

GoalMutating (avoid)Immutable (prefer)
Add to endarr.push(x)[...arr, x]
Add to startarr.unshift(x)[x, ...arr]
Remove by indexarr.splice(i, 1)arr.filter((_, idx) => idx !== i)
Update one itemarr[i] = xarr.map((v, idx) => idx === i ? x : v)
Sortarr.sort()[...arr].sort() or arr.toSorted()
const nums = [3, 1, 2];

const added = [...nums, 4];
const doubled = nums.map((n) => n * 2);
const evens = nums.filter((n) => n % 2 === 0);
const sorted = [...nums].sort((a, b) => a - b);

console.log(nums);   // unchanged
console.log(sorted); // new array

Output:

[ 3, 1, 2 ]
[ 1, 2, 3 ]

Tip: ES2023 added non-mutating array methods — toSorted(), toReversed(), toSpliced(), and with(index, value). They return a fresh array, so arr.with(0, 99) replaces an element without touching the original. They are supported in modern browsers and Node 20+.

Deep copies with structuredClone

Spread only copies one level deep — nested objects and arrays are still shared by reference. For a true deep copy, use the built-in structuredClone() (Node 17+ and all modern browsers).

const state = { user: { name: "Ada" }, tags: ["a", "b"] };

const shallow = { ...state };
shallow.user.name = "Grace"; // also changes state.user.name!

const deep = structuredClone(state);
deep.user.name = "Lin";      // state is safe

structuredClone handles nested structures, Map, Set, Date, and typed arrays — far more than the old JSON.parse(JSON.stringify(x)) trick, which drops functions, undefined, and special types. Note it cannot clone functions or DOM nodes and will throw if it encounters them.

Enforcing immutability with Object.freeze

Object.freeze() makes an object’s own properties non-writable and non-configurable, so accidental mutation fails (silently in sloppy mode, with a TypeError in strict mode).

"use strict";
const config = Object.freeze({ retries: 3, debug: false });

config.retries = 5; // TypeError: Cannot assign to read only property

Freeze is shallow — nested objects stay mutable. For full protection, freeze recursively:

function deepFreeze(obj) {
  for (const value of Object.values(obj)) {
    if (value && typeof value === "object") deepFreeze(value);
  }
  return Object.freeze(obj);
}

Performance and structural sharing

Copying on every update sounds wasteful, but in practice it is cheap: spreading an object copies references, not the deep contents. Unchanged nested objects are reused — this is structural sharing. A new top-level object points at the same untouched children, so only the path that actually changed is recreated.

const next = { ...state, user: { ...state.user, name: "Ada" } };
// next.tags === state.tags  → true (shared, not copied)

For very large or hot-path data structures, libraries like Immer (which lets you write mutable-looking code that produces immutable results) or persistent-collection libraries optimize this further. For typical app state, plain spread and map/filter are fast enough and keep your data predictable.

Best Practices

  • Default to copy-on-write: build new objects/arrays with spread, map, and filter instead of mutating.
  • Avoid the mutating array methods (push, splice, sort, reverse) on shared data; prefer toSorted, toReversed, with, and friends.
  • Use structuredClone() for deep copies instead of JSON.parse(JSON.stringify(...)).
  • Remember that spread is shallow — copy each nested level you intend to change.
  • Use Object.freeze() (or a deepFreeze helper) to catch accidental mutations during development.
  • Lean on structural sharing: reuse unchanged references so copies stay cheap.
  • Reach for Immer only when manual immutable updates become deeply nested and noisy.
Last updated June 1, 2026
Was this helpful?