Value Types vs Reference Types
The Copy That Isn't a Copy
Every JavaScript developer hits this bug eventually:
const config = { theme: 'dark', fontSize: 16 };
const backup = config;
backup.theme = 'light';
console.log(config.theme); // 'light' — wait, what?
You thought you made a copy. You didn't. You made a second pointer to the same object. When you mutated backup, you mutated config too — because they are the same object in memory.
This is not a quirk. It is the fundamental consequence of how JavaScript stores values: primitives live directly in the variable slot, objects live on the heap and variables hold references (pointers) to them.
Until this distinction is second nature, you will write bugs. They'll be subtle. They'll pass your tests. They'll break in production at 3am.
Primitives: Copied by Value
When you assign a primitive to a new variable, think of it as photocopying a sticky note. The copy is completely independent. Writing on the copy doesn't change the original. The seven primitive types in JavaScript — number, string, boolean, undefined, null, symbol, bigint — all behave this way.
let a = 42;
let b = a; // b gets its own copy of 42
b = 100;
console.log(a); // 42 — completely unaffected
let greeting = 'hello';
let copy = greeting;
copy = 'world';
console.log(greeting); // 'hello' — strings are primitives too
There is no way to mutate a through b, or vice versa. They are independent slots in memory that happen to hold the same value.
Small integers (Smi — Small Integer) from -2^30 to 2^30-1 are stored directly in the pointer slot with a tag bit, avoiding even a HeapNumber allocation. This makes integer-heavy code extremely efficient. When a number exceeds the Smi range or becomes a float, V8 boxes it into a HeapNumber on the heap — but it still behaves as a value type semantically.
Objects: Copied by Reference
When you assign an object to a variable, the variable stores a reference — essentially a memory address pointing to where the object lives on the heap. Assigning that variable to another variable copies the reference, not the object.
const original = { x: 1, y: 2 };
const alias = original;
// Both variables point to the same heap object
alias.x = 99;
console.log(original.x); // 99 — same object
console.log(original === alias); // true — same reference
This applies to all non-primitives: plain objects, arrays, functions, Maps, Sets, Dates, RegExps, Promises — every one of them.
const arr1 = [1, 2, 3];
const arr2 = arr1;
arr2.push(4);
console.log(arr1); // [1, 2, 3, 4] — same array
const fn1 = () => 'hello';
const fn2 = fn1;
console.log(fn1 === fn2); // true — same function object
The Function Parameter Trap
This is where it bites hardest. When you pass an object to a function, the function receives the reference, not a copy. The function can mutate the caller's data:
function addTimestamp(event) {
event.timestamp = Date.now(); // mutates the original!
return event;
}
const click = { type: 'click', target: 'button' };
addTimestamp(click);
console.log(click.timestamp); // exists — the original was mutated
Compare with primitives:
function double(n) {
n = n * 2; // reassigns the local copy
return n;
}
let value = 5;
double(value);
console.log(value); // 5 — unaffected
JavaScript is strictly pass-by-value, but the "value" for objects is the reference. You cannot reassign the caller's variable from inside a function — event = null inside addTimestamp would not affect the caller's click variable. But you can reach through the reference to mutate the underlying object. This is sometimes called "pass by sharing."
Shallow Copy: The Half-Fix
A shallow copy creates a new object and copies the top-level properties. References to nested objects are still shared.
const user = {
name: 'Alice',
settings: { theme: 'dark', lang: 'en' }
};
// Three ways to shallow copy
const copy1 = { ...user };
const copy2 = Object.assign({}, user);
const copy3 = Object.create(
Object.getPrototypeOf(user),
Object.getOwnPropertyDescriptors(user)
);
copy1.name = 'Bob';
console.log(user.name); // 'Alice' — top-level is independent
copy1.settings.theme = 'light';
console.log(user.settings.theme); // 'light' — nested object is shared!
For arrays, the equivalents are [...arr], arr.slice(), and Array.from(arr).
Deep Copy: The Real Fix
A deep copy recursively clones every nested object, producing a completely independent copy.
structuredClone (the modern way)
const user = {
name: 'Alice',
settings: { theme: 'dark', lang: 'en' },
scores: [100, 95, 87],
joined: new Date('2024-01-01')
};
const deepCopy = structuredClone(user);
deepCopy.settings.theme = 'light';
console.log(user.settings.theme); // 'dark' — fully independent
structuredClone handles nested objects, arrays, Dates, Maps, Sets, ArrayBuffers, and even circular references. It does not handle functions, DOM nodes, or symbols as property keys.
JSON.parse(JSON.stringify()) (the old way — avoid it)
const copy = JSON.parse(JSON.stringify(original));
This loses Date objects (become strings), undefined values (dropped), Map/Set (become {}), Infinity/NaN (become null), and any prototype chain. Use structuredClone instead.
When deep copy is too expensive
Deep copying large object graphs is O(n) in the size of the graph. For large state trees (like Redux stores), this is prohibitively expensive on every update. The solution is structural sharing: create a new object for the changed path, but reuse references to unchanged subtrees. This is what Immer does internally, and it's the principle behind persistent data structures in Clojure and Haskell. The new state shares most of its memory with the old state.
// Structural sharing — only copy what changed
const nextState = {
...state, // reuse unchanged top-level
settings: {
...state.settings, // reuse unchanged settings
theme: 'light' // only this changed
}
};
// state.scores === nextState.scores → true (same reference, no copy)Equality: === Compares References for Objects
const a = { x: 1 };
const b = { x: 1 };
const c = a;
console.log(a === b); // false — different objects, same shape
console.log(a === c); // true — same reference
// For value equality, you need a deep comparison
function deepEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b); // naive but illustrative
}
console.log(deepEqual(a, b)); // true
This is why React uses Object.is() (which is nearly identical to ===) for state comparison. If you return a mutated object from a state updater, React sees the same reference and skips the re-render:
// BUG: React won't re-render because the reference didn't change
const [items, setItems] = useState([1, 2, 3]);
items.push(4); // mutates the existing array
setItems(items); // same reference → React skips update
// FIX: Create a new array
setItems([...items, 4]); // new reference → React re-renders
| What developers do | What they should do |
|---|---|
| const copy = original (for objects) Assignment copies the reference, not the object | const copy = { ...original } or structuredClone(original) |
| Using spread for deep copy Spread only copies one level deep — nested objects are still shared | Use structuredClone() for objects with nested references |
| JSON.parse(JSON.stringify()) for cloning JSON round-tripping loses type information and fails on circular references | Use structuredClone() — handles Dates, Maps, Sets, circular refs |
| Mutating arrays/objects in React state React compares references. Same ref = no re-render, even if contents changed | Always create a new reference: [...arr], { ...obj } |
| Comparing objects with === === compares references for objects, not structural equality | Use a deep equality function or JSON.stringify for value comparison |
- 1Primitives (number, string, boolean, undefined, null, symbol, bigint) are always copied by value — fully independent.
- 2Objects, arrays, functions, and all non-primitives are accessed by reference — assignment copies the pointer, not the data.
- 3Shallow copy (spread, Object.assign, slice) copies one level. Nested objects remain shared.
- 4Deep copy (structuredClone) recursively clones the entire object graph. Use it when you need full independence.
- 5For large state trees, prefer structural sharing over deep copy — copy only the changed path.
- 6In React, always produce a new reference when updating state. Mutation + same reference = skipped re-render.