Shallow Copy vs Deep Copy in JavaScript
Short answer
A shallow copy duplicates only the top level of an object, so nested objects and arrays are still shared references — changing one changes both. A deep copy duplicates every level, producing a fully independent value. Spread and Object.assign are shallow; structuredClone is deep.
| Shallow copy | Deep copy | |
|---|---|---|
| Top-level properties | Duplicated | Duplicated |
| Nested objects and arrays | Shared by reference | Duplicated recursively |
| Mutating a nested value affects the original | Yes | No |
| Made with | { ...obj }, Object.assign, arr.slice() | structuredClone(obj) |
| Cost | Proportional to the top level | Proportional to the whole tree |
| Handles cycles | N/A | structuredClone yes, JSON no |
| Keeps functions | Yes (same reference) | No — structuredClone throws on them |
| Usual use | React state updates, merging options | Snapshots, undo history, isolating fixtures |
Why copying is confusing in the first place
JavaScript variables hold either a primitive value or a reference to an object. Assigning a primitive copies the value, so the two variables are independent from then on. Assigning an object copies only the reference, so both names point at the same thing in memory. Everything about shallow versus deep copies follows from that one rule applied at each level of nesting.
1const a = { count: 1 };
2const b = a; // copies the REFERENCE, not the object
3b.count = 2;
4console.log(a.count);
5// → 2 — one object, two names
6
7const x = 1;
8let y = x; // copies the VALUE
9y = 2;
10console.log(x);
11// → 1 — primitives are independentA shallow copy fixes this for the top level: it creates a genuinely new object whose properties hold the same values as the original. If those values are primitives, you are done. If they are references, the new object points at the very same nested objects — which is where the surprise lives.
How to make a shallow copy
There are several syntaxes and they all do the same thing. Object spread is the modern default; Object.assign predates it and is still common in library code. For arrays, spread, slice with no arguments, Array.from and concat on an empty array all produce a shallow copy.
1const user = { name: "Arun", address: { city: "Chennai" } };
2
3const c1 = { ...user };
4const c2 = Object.assign({}, user);
5const c3 = Object.fromEntries(Object.entries(user));
6
7console.log(c1 !== user);
8// → true (a new top-level object)
9console.log(c1.address === user.address);
10// → true (the SAME nested object, in all three)
11
12const nums = [1, [2, 3]];
13const a1 = [...nums], a2 = nums.slice(), a3 = Array.from(nums);
14console.log(a1[1] === nums[1]);
15// → true (arrays behave identically)1const original = { id: 1, tags: ["react"], meta: { seen: false } };
2const copy = { ...original };
3
4copy.id = 2; // fine — top level, independent
5copy.tags.push("hooks"); // NOT fine — same array
6copy.meta.seen = true; // NOT fine — same object
7
8console.log(original.id);
9// → 1
10console.log(original.tags);
11// → ["react", "hooks"] the "copy" mutated the original
12console.log(original.meta.seen);
13// → truestructuredClone: the built-in deep copy
structuredClone is a platform function available in every modern browser, in Node 17 and later, and in Deno and Bun. It walks the whole value and rebuilds it, so nothing is shared. It also handles the cases hand-rolled clones get wrong: circular references, Dates, RegExps, Maps, Sets, ArrayBuffers, and typed arrays.
1const state = {
2 when: new Date("2026-01-01"),
3 seen: new Set([1, 2]),
4 by: new Map([["a", { n: 1 }]]),
5 nested: { deep: { list: [1, 2, 3] } },
6};
7state.self = state; // a cycle
8
9const clone = structuredClone(state);
10
11console.log(clone.nested.deep.list === state.nested.deep.list);
12// → false fully independent
13console.log(clone.when instanceof Date, clone.seen instanceof Set);
14// → true true
15console.log(clone.self === clone);
16// → true the cycle was preserved, not expanded
17
18clone.nested.deep.list.push(4);
19console.log(state.nested.deep.list.length);
20// → 3 the original is untouchedThe limits are worth memorising because they come up as follow-up questions. structuredClone throws a DataCloneError on functions, on DOM nodes, and on symbols. It also does not preserve class identity: cloning an instance of your class produces a plain object with the same own properties, with the prototype and therefore the methods gone.
1structuredClone({ fn: () => 1 });
2// → DataCloneError: () => 1 could not be cloned
3
4class User { constructor(n) { this.n = n; } greet() { return "hi"; } }
5const u = structuredClone(new User(1));
6console.log(u.n, u instanceof User, typeof u.greet);
7// → 1 false "undefined" — plain object, prototype lost
8
9console.log(structuredClone({ [Symbol("k")]: 1 }));
10// → {} symbol-keyed properties are droppedThis is 1 of 80+ questions in the JavaScript Interview Kit
Get every question with detailed answers, follow-ups and real code — plus coding challenges and a last-minute revision sheet. One-time payment, instant access.
⚡ Get the JavaScript Interview Kit → ₹299The JSON round-trip and everything it breaks
JSON.parse(JSON.stringify(obj)) was the standard deep-copy trick for a decade, and it still works for plain data with only strings, numbers, booleans, null, arrays and plain objects. Outside that narrow range it does not error — it silently changes your data, which is worse than throwing.
| Value | Result | Note |
|---|---|---|
| Date | String | new Date(…) becomes an ISO string |
| undefined (in an object) | Key removed | The property disappears entirely |
| undefined (in an array) | null | Index preserved, value changed |
| Function | Key removed | Silently |
| Map / Set | {} | Emptied, not converted |
| NaN / Infinity | null | Numbers become null |
| BigInt | Throws | TypeError, the one loud failure |
| Circular reference | Throws | Converting circular structure to JSON |
1const before = { when: new Date(), missing: undefined, n: NaN, set: new Set([1]) };
2const after = JSON.parse(JSON.stringify(before));
3
4console.log(typeof after.when);
5// → "string" no longer a Date, and .getTime() now throws
6console.log("missing" in after);
7// → false the key is gone
8console.log(after.n, after.set);
9// → null {} NaN became null, the Set became an empty objectWhy this is really a React question
React decides whether to re-render by comparing references. Mutating nested state and passing the same top-level object back to the setter changes the data without changing the reference, so React skips the render and the screen silently disagrees with the state. Most of the shallow-copy questions asked in interviews are this bug wearing a different hat.
1const [user, setUser] = useState({ name: "Arun", address: { city: "Chennai" } });
2
3// Broken — same nested object, same top-level reference
4user.address.city = "Bengaluru";
5setUser(user);
6// → no re-render; the UI still shows Chennai
7
8// Also broken — new top level, but the nested object is shared,
9// so the mutation leaked into the previous state too
10setUser({ ...user });
11
12// Correct — copy every level on the path you are changing
13setUser(prev => ({
14 ...prev,
15 address: { ...prev.address, city: "Bengaluru" },
16}));
17// → new reference at each changed level, React re-rendersNotice the correct version is not a deep copy. It duplicates only the objects along the path from the root to the change and leaves every untouched branch shared. That is deliberate: sharing unchanged branches is what makes referential equality checks like React.memo work, and it is exactly what Immer and every immutable-update library produce for you.
Copy only the path you change
Deep copying an entire state tree on every keystroke is wasteful and it destroys memoisation, because every branch gets a new reference whether or not it changed. The middle ground — structural sharing — is what you want almost every time state is involved.
1// By hand: new objects only along the changed path
2const next = {
3 ...state,
4 ui: { ...state.ui, panel: { ...state.ui.panel, open: true } },
5};
6console.log(next.data === state.data);
7// → true untouched branch shared, so memoised consumers bail out
8console.log(next.ui === state.ui);
9// → false changed path got a fresh reference
10
11// With Immer: write it as a mutation, get the same result
12const next2 = produce(state, draft => { draft.ui.panel.open = true; });
13console.log(next2.data === state.data);
14// → true identical structural sharing, far less typingArrays, Maps, Sets and class instances
The same rule applies everywhere, but the shallow-copy syntax differs by type, and a few of them trip people up in interviews.
1const arr = [{ id: 1 }];
2const arrCopy = [...arr];
3console.log(arrCopy[0] === arr[0]);
4// → true elements are shared
5
6const map = new Map([["a", { n: 1 }]]);
7const mapCopy = new Map(map);
8console.log(mapCopy.get("a") === map.get("a"));
9// → true values are shared
10
11const set = new Set([{ n: 1 }]);
12const setCopy = new Set(set);
13console.log([...setCopy][0] === [...set][0]);
14// → true
15
16class Point { constructor(x) { this.x = x; } move() { this.x++; } }
17const p = new Point(1);
18const spread = { ...p };
19console.log(typeof spread.move);
20// → "undefined" spread copies own properties, never the prototype
21const proper = Object.assign(Object.create(Object.getPrototypeOf(p)), p);
22console.log(typeof proper.move);
23// → "function"That last case is the one worth remembering: spreading a class instance gives you a plain object with the fields but none of the methods, because methods live on the prototype and spread only copies own enumerable properties. structuredClone has the same limitation for the same reason.
Nested collections compound the problem rather than changing it. A Map whose values are objects needs both a new Map and new value objects before it is safe to mutate, and an array of arrays needs a copy at every level you intend to touch. When you find yourself writing three levels of nested spreads to express one change, that is the moment to reach for structuredClone if you want a true snapshot, or for Immer if you are producing the next state of something.
Is the spread operator a deep copy?
No — it is shallow. { ...obj } creates a new top-level object whose properties still reference the same nested objects and arrays. Mutating anything below the first level affects the original. Nested spreads copy each level you write out explicitly, and no further.
Is Object.assign different from spread?
For copying, no — both produce a shallow copy of own enumerable properties. Two differences matter in edge cases: Object.assign mutates its first argument, and it invokes setters on the target, whereas spread always defines plain properties on a fresh object.
How do I deep copy an object with functions in it?
structuredClone throws, and JSON drops them. Either restructure so functions live outside the data, or write a recursive clone that copies function properties by reference — which is what Lodash's cloneDeep does. Sharing a function reference is normally harmless, since functions are not usually mutated.
Is structuredClone slower than JSON.parse(JSON.stringify())?
It is generally faster, particularly on larger objects, because it works on the value directly instead of serialising to a string and reparsing it. It is also correct on Dates, Maps, Sets and cycles, which the JSON round-trip is not — so speed is rarely the deciding factor.
Does const prevent mutation of a copied object?
No. const stops you reassigning the binding, not changing the object it points at. A const object's properties are freely mutable at any depth. For shallow immutability use Object.freeze, and note that it too is one level deep unless you recurse.
Frequently asked questions
- What is the difference between a shallow copy and a deep copy in JavaScript?
- A shallow copy duplicates only the top-level properties, so nested objects and arrays are still shared references between the copy and the original. A deep copy recursively duplicates every level, so the two values are completely independent and no mutation can leak between them.
- Is spread a shallow or deep copy?
- Shallow. { ...obj } and [...arr] create a new container whose contents are the same references as before. Change a nested property through the copy and the original sees it too. Use structuredClone when you need genuine independence.
- How do I make a deep copy in JavaScript?
- structuredClone(value) is the built-in answer, available in modern browsers and Node 17 and later. It handles nested structures, Dates, Maps, Sets, typed arrays and circular references. For values containing functions or class instances, use a library clone such as Lodash's cloneDeep.
- Why does mutating my copy change the original?
- Because the mutation happened below the level you copied. A shallow copy shares every nested object, so copy.meta.seen = true writes into the same meta object the original holds. Copy each level on the path you are changing, or make a deep copy.
- Is JSON.parse(JSON.stringify()) a good way to deep copy?
- Only for plain JSON-shaped data. It converts Dates to strings, removes undefined values and functions, turns NaN and Infinity into null, empties Maps and Sets, and throws on circular references and BigInt. structuredClone does the same job without the data loss.
- What does structuredClone not support?
- Functions, DOM nodes and symbols cause a DataCloneError, and symbol-keyed properties are dropped. It also does not preserve prototypes, so a class instance comes back as a plain object with the same fields but none of its methods.
- How do I deep copy an array of objects?
- structuredClone(arr) copies every element recursively. For a one-level-deep array of plain objects, arr.map(o => ({ ...o })) is enough and is cheaper. Plain [...arr] is not enough — that shares every element object.
- Does Object.assign make a deep copy?
- No, it is shallow, exactly like spread. Object.assign({}, obj) copies own enumerable properties one level deep, so nested objects remain shared. Its one behavioural difference is that it mutates the target object you pass as the first argument.
- Why does React not re-render when I mutate state?
- React compares state by reference. Mutating a nested value leaves the top-level reference identical, so React concludes nothing changed and skips the render. Always produce a new object or array on the path you are changing and pass that to the setter.
- Should I deep copy React state before updating it?
- No — copy only the path you are changing and let untouched branches stay shared. Deep copying the whole tree gives every branch a new reference, which defeats React.memo and useMemo comparisons and does unnecessary work on every update.
- Does Object.freeze make a deep copy or deep immutability?
- Neither. It makes one object shallowly immutable — its own properties cannot be reassigned, but nested objects remain fully mutable. Deep freezing requires recursing over the tree, and it is a runtime check, not a copy.
- What is structural sharing?
- Producing a new value that reuses the unchanged parts of the old one, duplicating only the objects along the path to the change. It is what a correct React state update does by hand, what Immer produces from mutable-looking code, and what makes reference-equality checks meaningful.
This is 1 of 80+ questions in the JavaScript Interview Kit
Get every question with detailed answers, follow-ups and real code — plus coding challenges and a last-minute revision sheet. One-time payment, instant access.
⚡ Get the JavaScript Interview Kit → ₹299