Deep vs shallow copy, immutability, and the spread trap
The bug that ate your Saturday: you spread a state object, changed a nested field, and React refused to re-render. The reason is not React. It is JavaScript's copy semantics.
This lesson is about that distinction, and the patterns that prevent the bug forever.
Primitives vs references
Primitives (numbers, strings, booleans, null, undefined, symbol, bigint) copy by value. Objects, arrays, and functions copy by reference.
let a = 5; let b = a; b = 99;
console.log(a); // 5
let x = { n: 5 }; let y = x; y.n = 99;
console.log(x.n); // 99Step 1: a and b are independent slots in memory.
Step 2: x and y are two labels pointing at the same heap object.
Step 3: Mutating through one label is visible through the other.
Java, Python, and Go work the same for non-primitive types.
Spread is shallow, always
The spread operator and Object.assign copy one level deep. Nested objects are still shared by reference. Source of roughly half of all React state bugs.
const cart = {
user: "Asha",
address: { city: "Bengaluru", pin: "560001" },
};
const copy = { ...cart };
copy.address.pin = "110001";
console.log(cart.address.pin); // "110001"Predict output: "110001". The top-level user was copied. The nested address was not. Both cart and copy point at the same address object.
Interview trap: "How do I deep copy an object?" If the candidate says
{...obj}, they fail the round. That is a shallow copy. Senior answer:structuredClone(obj)for modern code, or a known-shape manual clone for hot paths.
Real deep clone options
Three real options. Pick based on what your data contains.
// Option 1: JSON round-trip (legacy, lossy)
const a = JSON.parse(JSON.stringify(cart));
// Option 2: structuredClone (modern, recommended)
const b = structuredClone(cart);
// Option 3: library (lodash.cloneDeep) if you need custom hooksJSON.parse(JSON.stringify(x))loses functions,undefined,Date(becomes string),Map,Set,Symbol, and crashes on circular references.structuredClone(x)handlesDate,Map,Set,ArrayBuffer, and circular refs. Available in Node 17+ and all modern browsers. Cannot clone functions or DOM nodes.- A library is only worth it for custom types needing control over the copy logic.
Senior rule: if your data is nested and you mutate it, every framework that depends on reference equality will silently break.
Freeze vs Seal
Two ways to lock an object. Both are shallow.
| Feature | Object.freeze | Object.seal |
|---|---|---|
| Add new property | No | No |
| Remove property | No | No |
| Change existing value | No | Yes |
| Configurable | No | No |
const order = Object.freeze({ id: "BIR-42", items: ["dosa"] });
order.id = "X"; // silently fails (throws in strict mode)
order.items.push("idli"); // works! freeze is shallowTo get deep immutability, write a recursive freeze:
function deepFreeze(o) {
Object.values(o).forEach(v => {
if (v && typeof v === "object" && !Object.isFrozen(v)) deepFreeze(v);
});
return Object.freeze(o);
}Reference equality and the React connection
Two objects with identical contents are not equal.
console.log({ a: 1 } === { a: 1 }); // false
const x = { a: 1 };
console.log(x === x); // trueReact's useMemo, useEffect, and React.memo all use Object.is (essentially ===) to decide if something changed. Mutate a nested field instead of producing a new top-level reference, and React sees the same pointer and skips the re-render.
Fix: replace the changed branch up to the root.
setCart(prev => ({
...prev,
address: { ...prev.address, pin: "110001" },
}));Shallow-copy every level you touch. Untouched branches keep their references, which is good for memo.
The || vs ?? quick table
Falsy guards bite when valid values are 0 or "".
| Input | x || "default" | x ?? "default" |
|---|---|---|
undefined | "default" | "default" |
null | "default" | "default" |
0 | "default" | 0 |
"" | "default" | "" |
false | "default" | false |
Use || when you genuinely want to fall back on any falsy value. Use ?? (nullish coalescing) when only null and undefined should trigger the default. For "discount percent" in a Swiggy coupon, 0 is a valid value, so ?? is correct.
Copy and immutability vocabulary
- Shallow copy
- A new top-level container whose nested values are still shared references with the original.
- Deep copy
- A fully independent duplicate where no nested value is shared with the original.
- structuredClone
- A built-in function that produces a deep copy. Handles Dates, Maps, Sets, and circular references.
- Referential equality
- Two variables are equal only if they point to the exact same object in memory, not if their contents match.
Closing
Memorise three lines.
- Spread is shallow.
structuredCloneis the modern deep copy.- Mutating nested state breaks reference equality and your re-renders.
If those are reflexes, you avoid the Saturday bug forever.
Quick quiz, prove you got it
- 1.What does `{...a}` do for nested objects?
- 2.What is wrong with `JSON.parse(JSON.stringify(obj))` as a deep clone?
- 3.Does `Object.freeze(obj)` freeze nested objects too?
Interview insights
When asked about deep vs shallow copy
- Memory model: stack vs heap, primitive vs reference
- Spread/Object.assign are shallow
- JSON trick + its limits
- structuredClone as the modern answer
- Why immutability matters for React state and referential equality
Tricky questions they will ask
Q.Is `a === b` true when both are `{x: 1}`?›
A.No. Each object literal creates a new object in heap. === compares references, not contents. They are equal by value but not by reference.
Q.Does the spread operator handle Date objects correctly?›
A.No. {...obj} on a Date will produce {} because Date stores its value internally, not as own enumerable properties. Use new Date(oldDate.getTime()) or structuredClone.
Free tools you can use while you learn
Common questions
Q.Does the spread operator do a deep copy?›
Q.What is the modern way to deep-clone an object in JS?›
Watching quietly. Tap me if you want a tip.
Try this (0 of 2 done)
- 1
Confirm that mutating copy.address.city changes the original too. (Output: Mumbai)
show answer
const original = { name: 'Aarav', address: { city: 'Bengaluru' } }; const copy = { ...original }; copy.address.city = 'Mumbai'; console.log(original.address.city); - 2
Use structuredClone to do a true deep clone and prove the original is NOT mutated.
show answer
const original = { name: 'Aarav', address: { city: 'Bengaluru' } }; const copy = structuredClone(original); copy.address.city = 'Mumbai'; console.log(original.address.city);