Skip to content

Value Types vs Reference Types

advanced13 min read

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

Mental Model

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.

V8's SMI Optimization

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
Execution Trace
Alloc
Heap: Object#1 = { x: 1, y: 2 }
Object allocated on heap, original gets reference to #1
Assign
alias = original → alias also points to Object#1
No new object created — just the reference is copied
Mutate
alias.x = 99 → Object#1.x is now 99
Both original and alias see the change — same object
Compare
original === alias → true
=== compares references, and they point to the same object

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
Common Trap

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!
Execution Trace
Original
user → { name: 'Alice', settings: → Settings#1 { theme: 'dark' } }
settings points to a heap object
Spread
copy1 → { name: 'Alice', settings: → Settings#1 }
New top-level object, but settings still points to Settings#1
Safe
copy1.name = 'Bob' → only copy1 affected
Primitive property was copied by value
Trap
copy1.settings.theme = 'light' → Settings#1 mutated
Both user and copy1 see the change

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 doWhat 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
Quiz
What does this code log?
Quiz
Why does structuredClone() exist when we already have spread and Object.assign?
Key Rules
  1. 1Primitives (number, string, boolean, undefined, null, symbol, bigint) are always copied by value — fully independent.
  2. 2Objects, arrays, functions, and all non-primitives are accessed by reference — assignment copies the pointer, not the data.
  3. 3Shallow copy (spread, Object.assign, slice) copies one level. Nested objects remain shared.
  4. 4Deep copy (structuredClone) recursively clones the entire object graph. Use it when you need full independence.
  5. 5For large state trees, prefer structural sharing over deep copy — copy only the changed path.
  6. 6In React, always produce a new reference when updating state. Mutation + same reference = skipped re-render.