JavaScriptIntermediate

JavaScript Value vs Reference: Copying and Shallow Copies

Understand primitive vs reference values in JavaScript, why copying objects surprises beginners, and how to make shallow and deep copies safely.

All JavaScript lessons

What you will learn

Have you ever copied an object, changed the copy, and then found that the original also changed? This is one of the most common surprises in JavaScript, and it all comes from one idea: values and references. In this lesson you will learn the difference between primitive values and reference values, how copying works for each, what happens when you pass them to functions, how to compare them, and how to make shallow copies and deep copies correctly.

Two kinds of data

JavaScript values come in two groups:

Group Types Stored as
Primitive string, number, boolean, null, undefined, bigint, symbol The value itself
Reference (objects) objects, arrays, functions, dates, Map, Set… A reference (an address) pointing to the object

Think of it like this:

  • A primitive is like writing a number on a sticky note. If you give someone a copy, they get their own sticky note.
  • An object is like a shared Google Doc. If you copy the link, both of you open the same document.

Primitives are copied by value

let a = 10;
let b = a;      // b gets its own copy of 10

b = 20;

console.log(a);   // 10 (unchanged)
console.log(b);   // 20

Changing b has no effect on a. They are fully separate.

This is true for every primitive, including strings:

let name1 = "Riya";
let name2 = name1;

name2 = "Karan";

console.log(name1);   // "Riya"
console.log(name2);   // "Karan"

Good to know: Primitives are immutable. A string method like toUpperCase() never changes the original string. It returns a new one.

Objects are copied by reference

const user1 = { name: "Riya" };
const user2 = user1;      // copies the REFERENCE, not the object

user2.name = "Karan";

console.log(user1.name);   // "Karan"
console.log(user2.name);   // "Karan"

There is only one object in memory. Both user1 and user2 point to it, so a change through one name is visible through the other.

The same thing happens with arrays:

const list1 = [1, 2, 3];
const list2 = list1;

list2.push(4);

console.log(list1);   // [1, 2, 3, 4]
console.log(list2);   // [1, 2, 3, 4]

Reassigning is different from changing

This is an important detail. Changing the object affects everyone who shares it. Reassigning a variable only changes that one variable:

let first = { score: 10 };
let second = first;

second = { score: 99 };   // second now points to a NEW object

console.log(first.score);    // 10 (first still points to the old object)
console.log(second.score);   // 99

const does not mean “unchangeable”

const settings = { theme: "dark" };

settings.theme = "light";   // allowed!
console.log(settings);      // { theme: "light" }

// settings = {};           // TypeError: Assignment to constant variable

const protects the variable (it cannot point somewhere else). It does not protect the contents of the object.

Passing to functions

When you pass a value to a function, JavaScript copies whatever is in the variable:

  • For a primitive, that is the value, so the function gets its own copy.
  • For an object, that is the reference, so the function can change the same object.
function addOne(number) {
  number = number + 1;
  return number;
}

let count = 5;
addOne(count);
console.log(count);   // 5 (not changed)
function rename(person) {
  person.name = "Changed";   // changes the shared object
}

const me = { name: "Riya" };
rename(me);
console.log(me.name);   // "Changed"

But if the function reassigns its parameter, the outside variable is not affected:

function replace(person) {
  person = { name: "Brand new" };   // only the local parameter changes
}

const me = { name: "Riya" };
replace(me);
console.log(me.name);   // "Riya"

Remember: JavaScript always passes a copy of what is in the variable. For objects, that copy is a reference to the same object. Some people call this “pass by sharing”.

Comparing values and references

Primitives are compared by value:

console.log(5 === 5);             // true
console.log("hi" === "hi");       // true

Objects are compared by reference (are they the very same object?):

const a = { x: 1 };
const b = { x: 1 };
const c = a;

console.log(a === b);   // false (two different objects, even with the same content)
console.log(a === c);   // true  (same reference)

console.log([1, 2] === [1, 2]);   // false

So === does not check the contents of objects. To compare contents, you need to compare property by property (or, for simple data, compare JSON strings).

const p1 = { x: 1, y: 2 };
const p2 = { x: 1, y: 2 };

console.log(JSON.stringify(p1) === JSON.stringify(p2));   // true (works for simple data only)

Making a copy: shallow copies

To get a separate object, you must create a new one. The easiest way is a shallow copy: a new outer object (or array) that contains the same top-level values.

Objects: spread and Object.assign

const user = { name: "Riya", age: 22 };

const copy1 = { ...user };
const copy2 = Object.assign({}, user);

copy1.name = "Karan";

console.log(user.name);    // "Riya" (safe)
console.log(copy1.name);   // "Karan"

Arrays: spread, slice and Array.from

const numbers = [1, 2, 3];

const copyA = [...numbers];
const copyB = numbers.slice();
const copyC = Array.from(numbers);

copyA.push(4);

console.log(numbers);   // [1, 2, 3] (safe)
console.log(copyA);     // [1, 2, 3, 4]

Copy and change in one step

Spread is great for “same object, but with one change”:

const user = { name: "Riya", age: 22, city: "Delhi" };

const updated = { ...user, city: "Mumbai" };

console.log(user.city);      // "Delhi" (original untouched)
console.log(updated.city);   // "Mumbai"

The shallow copy problem

A shallow copy copies only the first level. If a property holds another object or array, the reference is copied, so the original and the copy still share that inner object:

const original = {
  name: "Aman",
  address: { city: "Delhi" }
};

const shallow = { ...original };

shallow.name = "Neha";             // top level: safe
shallow.address.city = "Mumbai";   // nested: shared!

console.log(original.name);           // "Aman" (safe)
console.log(original.address.city);   // "Mumbai" (changed by mistake!)

The same happens with an array of objects:

const users = [{ name: "Riya" }, { name: "Karan" }];

const copy = [...users];

copy[0].name = "Changed";

console.log(users[0].name);   // "Changed" (the objects inside are shared)

Fix 1: copy the nested part too

const original = { name: "Aman", address: { city: "Delhi" } };

const safe = {
  ...original,
  address: { ...original.address }   // new inner object
};

safe.address.city = "Mumbai";
console.log(original.address.city);   // "Delhi" (safe)

Fix 2: a deep copy with structuredClone

A deep copy copies every level. The modern, built-in way is structuredClone:

const original = {
  name: "Aman",
  address: { city: "Delhi" },
  hobbies: ["cricket", "coding"]
};

const deep = structuredClone(original);

deep.address.city = "Mumbai";
deep.hobbies.push("music");

console.log(original.address.city);   // "Delhi"
console.log(original.hobbies);        // ["cricket", "coding"]

structuredClone can also copy Date, Map, Set and even circular references. But it cannot copy functions (it throws an error), and it drops class methods.

The old trick: JSON.parse(JSON.stringify(x))

const deep = JSON.parse(JSON.stringify(original));

It works for plain data, but it has limits:

  • Functions and undefined values are lost.
  • Date objects become strings.
  • NaN and Infinity become null.
  • Map and Set become empty objects.
  • Circular references throw an error.

Prefer structuredClone when it is available.

Frozen objects are also shallow

Object.freeze stops changes to an object, but only at the first level:

const config = Object.freeze({
  mode: "dark",
  limits: { max: 10 }
});

config.mode = "light";      // ignored
config.limits.max = 999;    // allowed! (the inner object is not frozen)

console.log(config.mode);         // "dark"
console.log(config.limits.max);   // 999

Real-life use cases

1. Updating state without changing the old one

Modern tools like React expect you to create new objects instead of changing old ones:

const state = { user: "Riya", cart: ["pen"] };

const newState = {
  ...state,
  cart: [...state.cart, "book"]
};

console.log(state.cart);      // ["pen"]
console.log(newState.cart);   // ["pen", "book"]

2. Default settings that must not be shared

const defaultSettings = { theme: "light", fontSize: 16 };

function createSettings(overrides) {
  return { ...defaultSettings, ...overrides };
}

const mine = createSettings({ theme: "dark" });

console.log(mine);              // { theme: "dark", fontSize: 16 }
console.log(defaultSettings);   // { theme: "light", fontSize: 16 } (safe)

3. A function that must not change its input

function addItem(cart, item) {
  return [...cart, item];   // return a new array
}

const cart = ["pen"];
const newCart = addItem(cart, "book");

console.log(cart);      // ["pen"]
console.log(newCart);   // ["pen", "book"]

4. A bug caused by sharing: the same default object

// Wrong: every student shares ONE marks array
const base = { marks: [] };
const s1 = { ...base, name: "Riya" };
const s2 = { ...base, name: "Karan" };

s1.marks.push(90);
console.log(s2.marks);   // [90] (Karan got Riya's marks!)

// Right: create a fresh array for each student
function createStudent(name) {
  return { name, marks: [] };
}

const t1 = createStudent("Riya");
const t2 = createStudent("Karan");

t1.marks.push(90);
console.log(t2.marks);   // []

5. Filling an array with the same object

// Wrong: all items are the SAME object
const bad = new Array(3).fill({ done: false });
bad[0].done = true;
console.log(bad.map((t) => t.done));   // [true, true, true]

// Right: make a new object for each item
const good = Array.from({ length: 3 }, () => ({ done: false }));
good[0].done = true;
console.log(good.map((t) => t.done));   // [true, false, false]

6. Undo feature: save a snapshot

const editor = { text: "Hello", style: { bold: false } };

const snapshot = structuredClone(editor);   // save the current state

editor.text = "Hello World";
editor.style.bold = true;

// Undo: go back to the snapshot
console.log(snapshot);   // { text: "Hello", style: { bold: false } }

Common mistakes

  • Thinking const copy = original makes a copy. It only copies the reference. Use { ...original } or [...original].
  • Thinking a spread copy is fully independent. It is shallow. Nested objects and arrays are still shared.
  • Expecting === to compare the contents of objects. { a: 1 } === { a: 1 } is false.
  • Thinking const makes an object unchangeable. It only stops reassigning the variable.
  • Changing a function’s parameter object by accident. Return a new object or array instead.
  • Using fill({}) or fill([]) to create many separate items. They all share one object. Use Array.from.
  • Using JSON.parse(JSON.stringify(x)) on data with dates, functions or undefined. Use structuredClone.
  • Thinking Object.freeze is deep. It only freezes the first level.

Practice

  1. Make let x = 5; let y = x; y = 10; and print both. Explain why x did not change.
  2. Create const a = { n: 1 }; const b = a; b.n = 2; and print a.n. Explain the result.
  3. Copy the array [1, 2, 3] in three different ways and change the copy. Check that the original did not change.
  4. Create an object { name, address: { city } }. Make a shallow copy, change the city in the copy, and see what happens to the original.
  5. Repeat the same test with structuredClone and compare the results.
  6. Write a function renameUser(user, newName) that returns a new user object without changing the old one.
  7. Create 3 separate { done: false } objects in an array using Array.from.
  8. Check what { a: 1 } === { a: 1 } gives, and write a small function isSame(obj1, obj2) that compares simple objects with JSON.stringify.
  9. Challenge: write your own deepCopy(value) function using recursion that works for nested objects and arrays.

Recap

  • Primitives (string, number, boolean, null, undefined, bigint, symbol) are copied by value. Each variable has its own copy.
  • Objects (objects, arrays, functions…) are copied by reference. Both variables point to the same object.
  • Changing a shared object affects every variable pointing to it. Reassigning a variable affects only that variable.
  • const protects the variable, not the contents of an object.
  • Functions receive a copy of the variable’s content. For objects, that is a reference, so a function can change the original object.
  • === compares primitives by value and objects by reference.
  • Make a shallow copy with { ...obj }, Object.assign({}, obj), [...arr], arr.slice() or Array.from(arr). It copies only the first level.
  • Make a deep copy with structuredClone(value). The JSON trick works only for plain data.
  • Next you will learn optional chaining and nullish coalescing, two small operators that make working with nested objects much safer.