You copy an array, sort the copy, and the original is sorted too. You pass an object to a function, and the caller's data changes behind its back. These bugs all come from one rule: variables hold primitives directly, but they only hold a reference to objects. This lesson explains the rule, then works through the bugs it causes and how to fix them.
The symptom
const original = { title: "JavaScript Basics", lessons: 12 };
const copy = original;
copy.lessons = 99;
console.log(original.lessons); // → 99
Nothing was copied. copy and original are two names for the same object.
Values and references
Primitives (strings, numbers, booleans, null, undefined, bigints and symbols) are stored as values. Assigning one copies the value, and primitives cannot be changed in place anyway:
let a = 10;
let b = a;
b += 5;
console.log(a); // → 10
console.log(b); // → 15
Objects, which include arrays, functions, maps, sets and dates, live somewhere in memory, and a variable holds a reference to that place. Assigning the variable copies the reference, not the object:
original ──┐
├──> { title: "JavaScript Basics", lessons: 99 }
copy ──────┘
Both arrows point to the same object, so a change made through either name is visible through both.
const does not freeze
const stops a variable from being reassigned. It says nothing about the object it points to:
const course = { lessons: 12 };
course.lessons = 13; // allowed: changes the object
// course = { lessons: 1 }; // TypeError: Assignment to constant variable.
Comparing objects
=== on objects compares references, not contents:
const a = { id: 1 };
const b = { id: 1 };
const c = a;
console.log(a === b); // → false (different objects, same contents)
console.log(a === c); // → true (same object)
console.log([1, 2] === [1, 2]); // → false
To compare contents, compare the fields that matter:
const sameCourse = (x, y) => x.id === y.id;
console.log(sameCourse(a, b)); // → true
Comparing JSON.stringify(a) === JSON.stringify(b) works for simple data, but it is fragile: key order matters, and values that JSON cannot represent, such as undefined, functions and dates, are dropped or converted. For anything complex, write a comparison that checks what your program actually cares about.
This is also why array.includes({ id: 1 }) returns false even when the array holds an object with id 1. Use array.some((item) => item.id === 1) instead.
Functions receive references too
When you pass an object to a function, the parameter gets a copy of the reference. Changing the object through the parameter changes the caller's object:
function markComplete(course) {
course.completed = true; // mutates the caller's object
}
const course = { title: "CSS Grid in Practice" };
markComplete(course);
console.log(course.completed); // → true
Reassigning the parameter, on the other hand, only changes the local name:
function reset(course) {
course = { title: "New" }; // points the local name somewhere else
}
const myCourse = { title: "Old" };
reset(myCourse);
console.log(myCourse.title); // → Old
A function that changes its arguments is said to have a side effect. Side effects are not always wrong, but they should be obvious from the function's name and documentation. Surprise mutation is where bugs hide.
Bug 1: sorting the dashboard's data
The course dashboard keeps the fetched courses in one array and renders sorted views of it:
const courses = [
{ id: 1, title: "JavaScript Basics", lessons: 12 },
{ id: 2, title: "CSS Grid in Practice", lessons: 8 },
{ id: 3, title: "Accessible Forms", lessons: 6 },
];
function sortedByTitle(list) {
return list.sort((a, b) => a.title.localeCompare(b.title));
}
const view = sortedByTitle(courses);
console.log(courses[0].title); // → Accessible Forms (the original order is gone!)
sort() sorts the array in place and returns the same array. The "view" and the stored data are one array, so the original order is lost, and any other code relying on it breaks. reverse(), splice(), push(), pop(), shift(), unshift() and fill() also change the array they are called on.
There are two fixes. Copy first, or use the non-mutating versions added in ES2023, which return a new array and are supported in all current browsers and Node.js 20:
function sortedByTitle(list) {
return list.toSorted((a, b) => a.title.localeCompare(b.title));
// or: return [...list].sort(...)
}
| Mutates | Returns a new array |
|---|---|
sort() | toSorted() |
reverse() | toReversed() |
splice() | toSpliced() |
arr[i] = value | arr.with(i, value) |
map, filter, slice and concat never mutated, so they are already safe.
Bug 2: shallow copies
Spread makes a new object, so changing a top-level property of the copy is safe:
const settings = { pageSize: 20, filters: { topic: "CSS", level: "Beginner" } };
const draft = { ...settings };
draft.pageSize = 50;
console.log(settings.pageSize); // → 20 (safe)
draft.filters.topic = "HTML";
console.log(settings.filters.topic); // → HTML (not safe!)
Spread, Object.assign(), slice() and Array.from() make shallow copies: only the top level is new. Nested objects are still shared references, so draft.filters and settings.filters are the same object.
Starting again from the original settings, there are two fixes.
Copy each level you change, which keeps unchanged parts shared and cheap:
const draft = {
...settings,
filters: { ...settings.filters, topic: "HTML" },
};
console.log(settings.filters.topic); // → CSS
console.log(draft.filters.topic); // → HTML
Or make a deep copy with structuredClone(), which copies every level:
const draft = structuredClone(settings);
draft.filters.topic = "HTML";
console.log(settings.filters.topic); // → CSS
structuredClone handles nested objects and arrays, dates, maps, sets and even circular references. It cannot copy functions or DOM nodes (it throws a DataCloneError), and class instances come back as plain objects without their methods. Lesson 24 compared the copying options in detail.
Bug 3: one object, many slots
const grid = Array(3).fill([]);
grid[0].push("x");
console.log(grid); // → [ [ 'x' ], [ 'x' ], [ 'x' ] ]
fill put the same array in every slot. Create a new one per slot with Array.from:
const grid = Array.from({ length: 3 }, () => []);
grid[0].push("x");
console.log(grid); // → [ [ 'x' ], [], [] ]
The same trap appears with default values shared between instances, for example one [] defined outside a function and handed to every new object.
Writing code that avoids mutation bugs
- Do not change what you did not create. Treat function arguments and data from other modules as read-only. Return a new value instead.
- Prefer non-mutating methods:
map,filter,toSorted, spread andstructuredClone. - Update immutably: build a new object with the change, rather than editing the old one.
function markComplete(course) {
return { ...course, completed: true };
}
const before = { title: "Accessible Forms", completed: false };
const after = markComplete(before);
console.log(before.completed); // → false
console.log(after.completed); // → true
- Freeze shared constants so accidental writes fail loudly.
Object.freeze()is shallow, as lesson 24 showed, but it catches the most common mistakes. In strict mode, including modules, writing to a frozen object throws aTypeError.
"use strict";
const DEFAULTS = Object.freeze({ pageSize: 20 });
DEFAULTS.pageSize = 50; // TypeError: Cannot assign to read only property 'pageSize' of object
Mutating your own local objects is fine and often clearer, for example pushing to an array you just created inside a function. The danger is mutating data that other code also holds.
Debugging a mutation bug
When a value changes and you do not know where, these steps help.
Beware live console objects
console.log(object) in a browser shows a live reference. When you expand it later, DevTools shows the object's current contents, not its contents when it was logged. To see a snapshot, log a copy:
console.log(structuredClone(settings));
console.log(JSON.stringify(settings));
Freeze to find the culprit
Temporarily freeze the object right after creating it. In strict mode, the first line that tries to change it throws, and the stack trace points straight at it. Remember that Object.freeze is shallow, so freeze the nested object if that is what changes.
Break when a property changes
In DevTools, you can set a breakpoint on DOM changes directly. For plain objects, a temporary setter does the same job:
let pageSize = settings.pageSize;
Object.defineProperty(settings, "pageSize", {
get: () => pageSize,
set(value) {
debugger; // pauses here with the call stack showing who changed it
pageSize = value;
},
});
Remove it once you have found the bug. Lesson 46 covers getters, setters and Proxy, which can watch every property at once.
Summary
- Primitives are copied by value. Objects, arrays and functions are shared by reference.
constprevents reassignment, not changes to the object.===compares object identity. Compare the fields you care about instead.sort,reverse,spliceand friends mutate in place; usetoSorted,toReversed,toSpliced,withor a copy.- Spread and
Object.assigncopy one level. Copy nested levels yourself or usestructuredClone. Array(n).fill(obj)puts the same object in every slot; useArray.from({ length: n }, () => ...).- Treat data you did not create as read-only and return new values.
- When debugging, log snapshots, freeze objects to find who writes to them, or break inside a temporary setter.