Primitives vs References

Mental model: Primitives are copied. Objects are shared.

Level: beginner · about 12 minutes

let a = 10;
let b = a;      // b gets a copy of the value
b = 20;
console.log(a, b);   // 10 20

const first = { score: 10 };
const second = first; // second gets a copy of the reference
second.score = 20;
console.log(first.score, second.score); // 20 20

Two assignments that look identical and behave completely differently.

A primitive is stored as the value. Assigning it hands over a copy, and the two variables have nothing to do with each other afterwards. An object is stored somewhere else, and the variable holds a reference to it. Assigning copies the reference, so both names point at one object.

primitives                        objects
  a --> [ 10 ]                     first  --\
  b --> [ 10 ]   (its own box)                >--> [ { score: 20 } ]
                                   second --/
  b = 20 changes only b            second.score = 20 is visible from first

Mutation is not reassignment

Mutation: same box, new contents

const cart = ['apple'];
cart.push('pear');
console.log(cart); // ['apple','pear']
// everyone holding cart sees this

Reassignment: new box, same label

let cart = ['apple'];
cart = ['pear'];
console.log(cart); // ['pear']
// other holders still see ['apple']

Mutation changes the object that everybody shares. Reassignment only repoints one label. Keeping these two apart is the whole skill.

How arguments are passed

JavaScript always passes arguments by value. For an object, the value that gets copied is the reference. So a function can mutate the object you handed it, but it can never repoint your variable.

function mutate(list) {
  list.push('added');   // reaches the caller's array
}

function reassign(list) {
  list = ['replaced'];  // only rebinds the local parameter
}

const items = ['original'];
mutate(items);
reassign(items);
console.log(items); // ['original', 'added']

Equality follows the same rule

console.log('abc' === 'abc');   // true, same value
console.log([1, 2] === [1, 2]); // false, two different objects

const shared = [1, 2];
console.log(shared === shared); // true, one object

Copying on purpose

const user = { name: 'Ada', tags: ['admin'] };

const shallow = { ...user };      // top level copied, tags still shared
shallow.name = 'Grace';
shallow.tags.push('editor');

console.log(user.name);  // 'Ada', the string was copied
console.log(user.tags);  // ['admin','editor'], the array was not
TechniqueDepthUse when
{ ...obj } / [...arr]one levelflat data, which is most data
Object.assign({}, obj)one levelyou need to merge several sources
structuredClone(obj)deepnested plain data, dates, Maps and Sets
JSON.parse(JSON.stringify(obj))deep, lossyalmost never: it drops functions, undefined and symbols, and breaks dates
const user = { name: 'Ada', tags: ['admin'] };
const deep = structuredClone(user);

deep.tags.push('editor');
console.log(user.tags); // ['admin'], untouched
console.log(deep.tags); // ['admin','editor']
function reset(list) {
  list = [];
  return list;
}

const items = [1, 2, 3];
reset(items);
console.log(items.length);

reset reassigns its own parameter, which is a separate label holding a copy of the reference. The caller array is never touched, so its length is still 3. Had the body been list.length = 0 or list.splice(0), it would have been 0.

Try it yourself

Share or copy

const state = { count: 0, user: { name: 'Ada' }, tags: ['a'] };

const shallow = { ...state };
shallow.count = 99;          // primitive: independent
shallow.user.name = 'Grace'; // object: shared
shallow.tags.push('b');      // object: shared

console.log('count:', state.count);
console.log('name:', state.user.name);
console.log('tags:', state.tags);

Change shallow to structuredClone(state) and rerun. Which log lines change, and which stay the same?

Exercises

Add a tag without mutating

Write addTag(post, tag) which returns a new post object with tag appended to its tags array. The post you were given, and its array, must come back untouched. Every other field is carried over.

Check yourself

What does this log?
1 2 — The spread created a second object, and n is a primitive so its value was copied. Changing b.n cannot reach a. Swap n: 1 for n: { v: 1 } and the answer changes, because then the copied value is a reference.
A function receives an object and does obj.total = 0. What does the caller see?
The change, because both names point at one object — The reference was copied, so the function is holding the same object. Mutating a property is visible everywhere. Reassigning the whole parameter would not be, which is the difference people trip on.
Why is [1, 2] === [1, 2] false?
=== compares references for objects, and these are two separate objects — For objects, === asks "is this the same object", not "do these look alike". Two array literals are two objects. To compare contents you need a deep comparison, which is what assertEqual does in these exercises.

Common mistakes

  • Assuming a spread copy is deep. It copies one level; nested objects stay shared.
  • Saying JavaScript is pass by reference. It passes a copy of the reference, so a function cannot repoint your variable.
  • Using === to compare two objects that hold equal data. It answers a different question.

Takeaways

  • Primitives are copied. Objects are shared. Everything else follows.
  • A function can mutate the object you pass, but it can never rebind your variable.
  • Spread and Object.assign copy one level only. Reach for structuredClone when the data nests.
  • === on objects compares identity, not contents.