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 20Two 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
| Technique | Depth | Use when |
|---|---|---|
{ ...obj } / [...arr] | one level | flat data, which is most data |
Object.assign({}, obj) | one level | you need to merge several sources |
structuredClone(obj) | deep | nested plain data, dates, Maps and Sets |
JSON.parse(JSON.stringify(obj)) | deep, lossy | almost 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
nis a primitive so its value was copied. Changingb.ncannot reacha. Swapn: 1forn: { 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 whatassertEqualdoes 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.assigncopy one level only. Reach forstructuredClonewhen the data nests. ===on objects compares identity, not contents.