ForgeFrontend — Prepare, Practice, Crack
Secure checkout
Lifetime access
Instant PDF download
Free updates forever

Prepare · Practice · Crack

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 copyDeep copy
Top-level propertiesDuplicatedDuplicated
Nested objects and arraysShared by referenceDuplicated recursively
Mutating a nested value affects the originalYesNo
Made with{ ...obj }, Object.assign, arr.slice()structuredClone(obj)
CostProportional to the top levelProportional to the whole tree
Handles cyclesN/AstructuredClone yes, JSON no
Keeps functionsYes (same reference)No — structuredClone throws on them
Usual useReact state updates, merging optionsSnapshots, undo history, isolating fixtures
The only question that matters: how far down does the duplication go?

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.

Assignment is not a copy at alljavascript
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 independent

A 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.

Every shallow copy, and what it sharesjavascript
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)
The bug this producesjavascript
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// → true

structuredClone: 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.

A real deep copy, including the awkward typesjavascript
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 untouched

The 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.

What it refuses, and what it quietly flattensjavascript
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 dropped

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

The 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.

ValueResultNote
DateStringnew Date(…) becomes an ISO string
undefined (in an object)Key removedThe property disappears entirely
undefined (in an array)nullIndex preserved, value changed
FunctionKey removedSilently
Map / Set{}Emptied, not converted
NaN / InfinitynullNumbers become null
BigIntThrowsTypeError, the one loud failure
Circular referenceThrowsConverting circular structure to JSON
What survives a JSON round-trip.
Silent data lossjavascript
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 object

Why 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.

The update that does nothing, and the one that worksjsx
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-renders

Notice 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.

Structural sharing, by hand and with Immerjavascript
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 typing

Arrays, 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.

Shallow copies by typejavascript
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
Written by Arun Karthikeyan · Last updated

Full kit

JavaScript Interview Kit · ₹299

Get it →