Saltar al contenido
Clinford Jordano Valencia
Todos los artículos

JavaScript

The Hidden Cost of Mutable State in JavaScript

Aliasing, shallow copies and in-place methods cause bugs that show up far from their cause. How references work, and when to copy, freeze or isolate state.

  • 7 min de lectura

Esta página aún no está disponible en español, así que estás leyendo el original en inglés.

Most JavaScript mutation bugs have the same shape: two parts of the program hold a reference to the same object, one changes it, and the other is surprised. The fix is rarely “never mutate”. It is knowing who owns a piece of data, copying at the boundaries, and choosing methods that don’t change their input. This post shows how references work, the built-in methods that mutate quietly, and a practical policy for when to mutate and when to copy.

Why It Matters

Mutation bugs are expensive because the symptom appears far from the cause:

  • A function three files away sorts an array “just to read it”, and a table elsewhere renders in the wrong order.
  • A React component pushes into an array in state, and nothing re-renders.
  • A cache returns an object that a caller then edits, corrupting the cache for everyone.

None of these crash; they produce subtly wrong data.

Variables Hold References, Not Objects

Primitives (numbers, strings, booleans, null, undefined, symbols, bigints) are values. Objects, arrays, maps, dates and functions live on the heap, and a variable holds a reference to them. Assigning or passing an object copies the reference, not the object:

useradmincopy{ name: "Ada",tags: ↗ }{ name: "Ada",tags: ↗ }["admin"]same objectspread: new objectshared array
Two variables, one object. A change through either name is a change to the shared object.
const user = { name: 'Ada', tags: ['admin'] };
const copy = { ...user };
 
copy.name = 'Grace'; // only the copy changes
copy.tags.push('owner'); // the nested array is shared
 
user.name; // 'Ada'
user.tags; // ['admin', 'owner']

Spread and Object.assign make shallow copies: one new object, the same nested references. For a deep copy of plain data, use structuredClone, which handles nested objects, arrays, Map, Set and Date. It throws a DataCloneError on functions and DOM nodes, and class instances come back as plain objects, without their prototype or #private fields.

const doesn’t help here. It stops you from pointing the variable somewhere else, not from changing what it points to.

Methods That Mutate Quietly

Many array methods change the array you call them on. The dangerous ones look like they only read:

const original = [3, 1, 2];
const sorted = original.sort();
 
original; // [1, 2, 3]: changed!
sorted === original; // true: same array
Mutates the arrayReturns a new array instead
sort()toSorted()
reverse()toReversed()
splice()toSpliced()
arr[i] = xwith(i, x)
push, pop, shift, unshift, fill, copyWithinspread, concat, slice, filter, map

The right-hand methods (ES2023) are available in every current browser and Node.js 20+. Date setters (setFullYear, setDate…) also mutate, which is a classic source of bugs when one date object is shared between a form and a calendar.

Where It Bites

Functions That Edit Their Arguments

function addItem(cart, item) {
  cart.items.push(item); // edits the caller’s object
  return cart;
}

The caller may not expect cart to change, and returning it makes it look like a new value. Either name the function after what it does (addItemInPlace) or return a new object: return { ...cart, items: [...cart.items, item] }.

React and Other Change-Detecting UIs

React decides whether to re-render by comparing references. Mutating state in place keeps the same reference, so nothing updates:

// Bug: same array, React sees no change
setTodos((todos) => {
  todos.push(newTodo);
  return todos;
});
 
// Fix: a new array
setTodos((todos) => [...todos, newTodo]);

The same rule drives useMemo and useEffect dependencies, Redux selectors and Angular OnPush: if the reference didn’t change, the framework assumes the data didn’t either.

Caches and Module-Level Objects

  • A cache that returns its stored object lets every caller edit the shared copy. Return a copy, or freeze what you store.
  • Module-level objects are singletons (each module runs once), so mutating one changes it for every importer. See how modules share state.

Async Code

When two async operations read the same object, modify it and write it back, they can interleave and lose updates. That is a race condition, covered in detail in race conditions in JavaScript.

A Practical Policy

You don’t need a library or a rule against mutation. You need clear ownership:

  1. Receive data: treat it as read-only
  2. Copy at the boundary if you need to change it
  3. Mutate freely inside the function that owns the copy
  4. Return the new value; don’t edit the input
  • Mutate locally, share immutably. Building an array with push inside a function is fine; nobody else can see it yet. Once you hand it out, stop changing it.
  • Freeze shared configuration. Object.freeze makes writes fail loudly in strict code (every module is strict). It is shallow, so freeze nested objects too, or freeze only in development to catch mistakes.
  • Use structural sharing for large state. Copy the path you change and reuse the rest: { ...state, user: { ...state.user, name } }. Immer does this for you with a mutable-looking API.
  • Type it. TypeScript’s readonly and Readonly<T> turn many mutation bugs into compile errors, at zero runtime cost.

Going Deeper: Copying Without Paying for It

“Copy instead of mutate” sounds expensive: does every update copy the whole state? No. An immutable update copies only the path from the root to the value that changed, and every untouched branch is reused by reference. This is called structural sharing:

Beforestateusersettingsname: Adatheme: darkAfter setting the namestate′user′name: Gracesettings is reused: same object, not a copy
Structural sharing: the update creates new objects only along the path to the change. Unchanged branches are shared, so the copy costs a few small objects, not the whole tree.
const next = {
  ...state, // copies two references, not the whole tree
  user: { ...state.user, name: 'Grace' },
};
next.settings === state.settings; // true: shared
next.user === state.user; // false: the changed path is new

That is also why change detection is cheap: React, Redux selectors and memoization compare references (Object.is), and only the changed path has new ones.

Tools for the cases where hand-written spreads get messy:

ToolWhat it doesWatch out for
toSorted, toReversed, toSpliced, withNon-mutating versions of array methods (ES2023)Supported in all current browsers and Node.js 20+; older runtimes need a polyfill
structuredClone(value)A real deep copy: nested objects, arrays, Map, Set, Date and even cyclesThrows on functions and DOM nodes; class instances come back as plain objects without their prototype
Object.freeze(obj)Makes an object’s own properties read-onlyShallow: nested objects stay mutable. Useful in development to catch accidental writes
Immer (produce)Write “mutating” code against a draft; get a structurally shared copyA dependency and a proxy layer; best for deeply nested updates

The senior-level rule of thumb: immutability is a boundary decision, not a religion. Treat shared state, props, cached values and anything crossing module or async boundaries as immutable; mutate freely inside a function on objects it created and nobody else can see yet.

When Mutation Is the Right Choice

SituationMutate?Why
Building a result inside one functionYesNobody else holds a reference yet
A tight loop over large dataYesCopying every step costs allocations and GC time
State read by a UI frameworkNoChange detection relies on new references
Data passed into your functionNoThe caller owns it
Shared caches and configurationNoEvery reader sees the change

Test Yourself

Predict the output, then open the answer. Every answer was checked by running the code.

Reto 1

A Shallow Copy

const a = { tags: ['x'] };
const b = { ...a };
b.tags.push('y');
b.name = 'copy';
console.log(a.tags, a.name);
Ver respuesta

[ 'x', 'y' ] undefined

  • Spread copies the top level: b is a new object, so adding name to it doesn’t touch a.
  • b.tags holds the same array reference as a.tags, so pushing to it changes the array both share.

Reto 2

Sorting Numbers

const scores = [10, 9, 1];
const sorted = scores.sort();
console.log(scores, sorted === scores);
Ver respuesta

[ 1, 10, 9 ] true. Two surprises in one line:

  • sort mutates the array and returns the same array, so scores changed too.
  • Without a compare function it sorts by string order, where '10' comes before '9'.
  • Fix: scores.toSorted((a, b) => a - b) returns a new, numerically sorted array.

Reto 3

Freezing a Config

const config = Object.freeze({ retries: 3, limits: { max: 10 } });
config.limits.max = 99;
try {
  config.retries = 5;
} catch (error) {
  console.log(error.constructor.name);
}
console.log(config.retries, config.limits.max);
Ver respuesta

TypeError, then 3 99

  • Writing to a frozen property throws in strict mode (modules and classes are strict).
  • Object.freeze is shallow: limits is a different object that was never frozen, so its max changed.
  • Freeze nested objects separately if you need deep protection.

Reto 4

Default Parameters

function add(item, list = []) {
  list.push(item);
  return list;
}
console.log(add(1), add(2));
Ver respuesta

[ 1 ] [ 2 ]

  • In JavaScript a default parameter is evaluated on every call, so each call gets a fresh array.
  • Python is the opposite: a default list is created once and shared between calls, a classic bug there.

Reto 5

A Deep Copy

const original = { when: new Date(0), seen: new Map([['a', 1]]) };
const copy = structuredClone(original);
copy.seen.set('b', 2);
console.log(original.seen.size, copy.when instanceof Date, copy.when === original.when);
Ver respuesta

1 true false

  • structuredClone makes a deep copy that understands Map, Set and Date: the copy has its own map and its own Date, so changes don’t leak back.
  • JSON.parse(JSON.stringify(x)) would have turned the date into a string and the map into {}.

Reto 6

Filling a Grid

const grid = Array(2).fill([]);
grid[0].push('x');
console.log(grid);
Ver respuesta

[ [ 'x' ], [ 'x' ] ]

  • fill puts the same array object in every slot, so both rows are one array.
  • Fix: create a new array per row, Array.from({ length: 2 }, () => []).

Key Takeaways

  • Variables hold references to objects, so assigning or passing an object shares it.
  • Spread and Object.assign copy one level; nested objects stay shared. Use structuredClone for deep copies.
  • sort, reverse and splice mutate; use toSorted, toReversed and toSpliced when you only want a result.
  • UI frameworks detect changes by reference, so update state with new objects, not in place.
  • Decide who owns each piece of data: mutate only what you own, copy at boundaries, and return new values.

Sources

Etiquetas

Preguntas frecuentes

Is const enough to make something immutable?

NoConst stops you from reassigning the variable, but the object it points to can still change. Use Object.freeze for a shallow read-only object, or copy the data so nobody else holds a reference.

Is spread a deep copy?

No{ ...obj } and [...arr] copy one level. Nested objects and arrays are shared between the copy and the original. Use structuredClone for a deep copy of plain data.

Is immutability slow?

NoNot usually. Copying has a cost, but for typical UI state it is small, and structural sharing (copy only the path you change) keeps it cheap. Mutating in place is still the right call inside a tight loop on data that only that function can see.

What is structuredClone, and when should I use it?

A built-in deep copy available in browsers and Node.js 17+. It copies nested objects, arrays, Map, Set, Date and cycles. Use it when you truly need an independent copy of data; it throws on functions and DOM nodes and drops class prototypes.

Why doesn’t React re-render when I push to an array in state?

React compares the old and new state by reference. Pushing changes the array but keeps the same reference, so React sees no change. Create a new array instead, for example setItems([...items, item]).

Should I use a library such as Immer?

It helps when updates reach several levels deep, because you write simple assignments on a draft and still get a structurally shared copy. For shallow state, plain spreads and the non-mutating array methods are enough.

How do I find out which code mutated my object?

In development, freeze the object (Object.freeze) so the offending write throws with a stack trace, or wrap it in a Proxy that logs writes. Browser DevTools can also pause on a property change with a conditional breakpoint.

Does Object.freeze make an object deeply immutable?

NoIt freezes only the object’s own properties; nested objects stay mutable. It is mainly useful in development to catch accidental writes early.