Values and references: copying and mutation bugs

Find out why changing a copy changed the original, and write code that keeps shared data safe.

  • Beginner
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Explain value versus reference
  • Avoid mutating shared data
  • Compare objects correctly
  • Diagnose a mutation bug with DevTools

Before you start

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

JavaScript
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:

JavaScript
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:

Plain text
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
function sortedByTitle(list) {
  return list.toSorted((a, b) => a.title.localeCompare(b.title));
  // or: return [...list].sort(...)
}
MutatesReturns a new array
sort()toSorted()
reverse()toReversed()
splice()toSpliced()
arr[i] = valuearr.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:

JavaScript
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:

JavaScript
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:

JavaScript
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

JavaScript
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:

JavaScript
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 and structuredClone.
  • Update immutably: build a new object with the change, rather than editing the old one.
JavaScript
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 a TypeError.
JavaScript
"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:

JavaScript
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:

JavaScript
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.
  • const prevents reassignment, not changes to the object.
  • === compares object identity. Compare the fields you care about instead.
  • sort, reverse, splice and friends mutate in place; use toSorted, toReversed, toSpliced, with or a copy.
  • Spread and Object.assign copy one level. Copy nested levels yourself or use structuredClone.
  • Array(n).fill(obj) puts the same object in every slot; use Array.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.

Further reading