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분 분량
이 페이지는 아직 한국어로 제공되지 않아 영어 원문을 보여 드립니다.
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:
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 array | Returns a new array instead |
|---|---|
sort() | toSorted() |
reverse() | toReversed() |
splice() | toSpliced() |
arr[i] = x | with(i, x) |
push, pop, shift, unshift, fill, copyWithin | spread, 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:
- Receive data: treat it as read-only
- Copy at the boundary if you need to change it
- Mutate freely inside the function that owns the copy
- Return the new value; don’t edit the input
- Mutate locally, share immutably. Building an array with
pushinside a function is fine; nobody else can see it yet. Once you hand it out, stop changing it. - Freeze shared configuration.
Object.freezemakes 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
readonlyandReadonly<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:
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 newThat 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:
| Tool | What it does | Watch out for |
|---|---|---|
toSorted, toReversed, toSpliced, with | Non-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 cycles | Throws 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-only | Shallow: nested objects stay mutable. Useful in development to catch accidental writes |
Immer (produce) | Write “mutating” code against a draft; get a structurally shared copy | A 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
| Situation | Mutate? | Why |
|---|---|---|
| Building a result inside one function | Yes | Nobody else holds a reference yet |
| A tight loop over large data | Yes | Copying every step costs allocations and GC time |
| State read by a UI framework | No | Change detection relies on new references |
| Data passed into your function | No | The caller owns it |
| Shared caches and configuration | No | Every reader sees the change |
Test Yourself
Predict the output, then open the answer. Every answer was checked by running the code.
챌린지 1
A Shallow Copy
const a = { tags: ['x'] };
const b = { ...a };
b.tags.push('y');
b.name = 'copy';
console.log(a.tags, a.name);정답 보기정답 숨기기
[ 'x', 'y' ] undefined
- Spread copies the top level:
bis a new object, so addingnameto it doesn’t toucha. b.tagsholds the same array reference asa.tags, so pushing to it changes the array both share.
챌린지 2
Sorting Numbers
const scores = [10, 9, 1];
const sorted = scores.sort();
console.log(scores, sorted === scores);정답 보기정답 숨기기
[ 1, 10, 9 ] true. Two surprises in one line:
sortmutates the array and returns the same array, soscoreschanged 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.
챌린지 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);정답 보기정답 숨기기
TypeError, then 3 99
- Writing to a frozen property throws in strict mode (modules and classes are strict).
Object.freezeis shallow:limitsis a different object that was never frozen, so itsmaxchanged.- Freeze nested objects separately if you need deep protection.
챌린지 4
Default Parameters
function add(item, list = []) {
list.push(item);
return list;
}
console.log(add(1), add(2));정답 보기정답 숨기기
[ 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.
챌린지 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);정답 보기정답 숨기기
1 true false
structuredClonemakes a deep copy that understandsMap,SetandDate: the copy has its own map and its ownDate, so changes don’t leak back.JSON.parse(JSON.stringify(x))would have turned the date into a string and the map into{}.
챌린지 6
Filling a Grid
const grid = Array(2).fill([]);
grid[0].push('x');
console.log(grid);정답 보기정답 숨기기
[ [ 'x' ], [ 'x' ] ]
fillputs 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.assigncopy one level; nested objects stay shared. UsestructuredClonefor deep copies. sort,reverseandsplicemutate; usetoSorted,toReversedandtoSplicedwhen 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
- MDN: Shallow copy and structuredClone()
- MDN: Array.prototype.toSorted()
- React docs: Updating arrays in state
- Immer: Introduction
- TC39: Change Array by copy
태그
자주 묻는 질문
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.