Shallow copy vs deep copy in JavaScript, and the React bug
Tien Nguyen
· 7 min read
Shallow copy vs deep copy is the difference behind a bug I see AI write constantly: it spreads a piece of React state, mutates a nested field, and quietly corrupts the state it meant to replace while a memoized child stops updating. If you have ever changed a copy and watched the original change with it, that shared reference is the whole problem.
A shallow copy duplicates only the top level, and shares every nested object by reference. A deep copy duplicates everything, all the way down, so it is fully independent. The spread operator makes a shallow copy. For a deep copy in 2026, reach for the built-in structuredClone, not the old JSON.parse(JSON.stringify()) trick. Most of the pain with copying in JavaScript comes from thinking you made a deep copy when you made a shallow one.

What’s the difference between shallow and deep copy?
Here it is in one runnable example. A shallow copy of an object with a nested object:
const original = { name: 'Tien', address: { city: 'Hanoi' } };const shallow = { ...original };
shallow.address.city = 'Da Nang';original.address.city; // 'Da Nang' — the original changed tooThe spread copied name and address, but shallow.address is the same object as original.address, not a copy of it. So mutating shallow.address.city mutates the one shared nested object. The top level is independent; everything below it is shared. That is a shallow copy, and it is the default behavior of almost every built-in copy in JavaScript.
How do you make a shallow copy in JavaScript?
You already have three common ways, and they are all shallow:
const copy1 = { ...original }; // spread (objects)const copy2 = Object.assign({}, original); // Object.assignconst arrCopy = [...list]; // spread (arrays); .slice() and .map() tooThese are perfect when your data is flat, or when you only intend to replace whole top-level properties. Spreading an array of objects ([...users]) is the same story: you get a new array, but every object inside it is still the original, shared. They become a trap the moment you reach into a nested object or array and change it in place.
How do you deep copy in JavaScript? Use structuredClone()
The modern answer is a built-in global, structuredClone (in every current browser and Node 17+). It copies all the way down, so the result shares nothing with the original:
const original = { name: 'Tien', address: { city: 'Hanoi' }, joined: new Date('2020-01-01') };const deep = structuredClone(original);
deep.address.city = 'Hue';original.address.city; // 'Hanoi' — untoucheddeep.joined instanceof Date; // true — Dates surviveFor years the go-to trick was JSON.parse(JSON.stringify(obj)), and you will still find it at the top of old answers. It does deep-copy plain data, but it silently corrupts anything JSON cannot represent:
const data = { joined: new Date('2020-01-01'), greet: () => 'hi', role: undefined };const clone = JSON.parse(JSON.stringify(data));
typeof clone.joined; // 'string' — the Date became textclone.greet; // undefined — the function was dropped'role' in clone; // false — undefined values disappearSo the three methods break in different places. Pick by what your data actually contains:
| Method | Deep? | Where it breaks |
|---|---|---|
{ ...obj } / Object.assign | No (shallow) | Any nested object or array, shared by reference |
JSON.parse(JSON.stringify(obj)) | Plain data only | Dates become strings, functions and undefined dropped, throws on circular refs |
structuredClone(obj) | Yes | Functions and DOM nodes (throws); class instances flatten to plain objects |
lodash cloneDeep(obj) | Yes | Silently returns {} for functions/DOM nodes (same limit as structuredClone, just quiet); adds a dependency |
For everyday objects, structuredClone is the right default. Reach for cloneDeep mainly for older environments without structuredClone, or when you want a cloner that skips uncloneable values instead of throwing. Neither one truly clones a function; there is no clean way to.
Why does spreading React state still cause a bug?
Here is where shallow-vs-deep stops being trivia. React state must be updated immutably, and the shallow-copy trap breaks that in a way that is easy to miss:
// user = { name: 'Tien', address: { city: 'Hanoi' } }setUser((prev) => { const next = { ...prev }; // shallow: next.address IS prev.address next.address.city = 'Da Nang'; // mutates the CURRENT state's nested object return next;});next.address is the same reference as prev.address, so that line mutates the existing state object in place. Two things go wrong. You have corrupted the previous state, which breaks anything relying on it staying constant (an undo stack, a memoized value). And because that nested reference never changed, a memoized child reading address, or a useEffect/useMemo keyed on user.address, quietly stops updating even though the city did. The fix is to copy each level you actually change:
setUser((prev) => ({ ...prev, address: { ...prev.address, city: 'Da Nang' }, // a new address object too}));This is exactly the bug an AI assistant writes when it spreads state and reaches into a nested field, because the code looks correct and runs. If you understand shallow vs deep, you catch it in review. If you do not, you get a component that “won’t update” for reasons that make no sense. It is the same class of state trap I cover in is React easy to learn, and understanding it is part of directing AI instead of being fooled by it.
When do you actually need a deep copy?
Rarely, and that is the part people get backwards. For React updates you almost never want to deep-clone the whole tree. You want a new object for the specific path you are changing, spreading each level, and leaving every untouched branch shared. That is cheaper and it is what React expects.
Reach for a real deep copy (structuredClone, or an immutability helper like Immer for deeply nested updates) only when you genuinely need a fully independent snapshot: cloning a config object you are about to mutate freely, capturing a value before an operation, or handing data to code you do not trust to leave it alone. Deep-cloning on every state update is a common reflex and a quiet performance cost.
The takeaway
Shallow copy shares your nested objects; deep copy does not. The spread operator is shallow, structuredClone is your deep copy, and JSON.parse(JSON.stringify()) is a legacy trick that corrupts Dates, functions, and undefined without telling you. The one bug to internalize is mutating a nested object after a shallow copy, especially in React state, because it is invisible until something does not re-render.
Know which copy you made, copy each level you change, and reach for a full deep clone only when you truly need independence. Nearly every copying bug is the same story: a nested reference you shared without meaning to. Once you can spot it, the “why did the original change?” surprises stop, in plain objects and in React state alike. It is core JavaScript, right next to closures and how this works, and one of the parts of JavaScript that are actually hard.
Frequently asked questions
What is the difference between shallow copy and deep copy in JavaScript?
A shallow copy duplicates only the top level of an object or array. Any nested objects or arrays are shared by reference, so changing a nested value in the copy also changes it in the original. A deep copy duplicates everything, all the way down, so the copy is fully independent and nothing you do to it touches the original. The spread operator and Object.assign make shallow copies; structuredClone makes a deep copy.
How do you deep copy an object in JavaScript?
In modern JavaScript, use the built-in structuredClone(obj). It deep-copies nested objects and arrays, preserves Dates, Maps, and Sets, and even handles circular references. The old trick, JSON.parse(JSON.stringify(obj)), still works for plain data but silently loses Dates (they become strings), drops functions and undefined values, and throws on circular references. Reach for structuredClone first; use a library like lodash cloneDeep mainly for older environments without structuredClone, or when you want a cloner that skips uncloneable values instead of throwing. Neither one actually clones functions.
Is the spread operator a deep copy?
No. The spread operator ({...obj} or [...arr]) makes a shallow copy. It copies the top-level properties, but any nested object or array is still shared by reference with the original. So spreading a piece of state and then mutating a nested field inside it will mutate the original too. If you need the nested levels to be independent, either spread each level you change, or make a deep copy with structuredClone.
Does JSON.parse(JSON.stringify()) make a good deep copy?
Only for plain JSON-safe data. It does produce a deep copy of nested objects and arrays, which is why it was the go-to trick for years. But it silently corrupts anything JSON cannot represent: Dates turn into strings, functions and undefined values disappear, Map and Set become empty objects, and a circular reference throws an error. For real objects, prefer structuredClone, which handles all of those correctly except functions.
How do you deep copy React state?
Usually you should not deep copy the whole state. React state must be updated immutably, but you only need new objects for the levels you actually change. Spread the path you are updating (for example, {...prev, address: {...prev.address, city}}), leaving the untouched branches shared. Deep-clone the whole thing only when you genuinely need a fully independent snapshot; reaching for structuredClone or an immutability helper like Immer on every update is slower and rarely necessary.
Why do AI coding assistants cause this shallow-copy bug?
AI assistants are trained on a lot of React, and they reach for the spread operator to update state because it usually looks right. When the state is flat, spreading it is correct. When it has a nested object, the spread copies only the top level and shares the nested one, so a follow-up line that changes a nested field mutates the original state. The code runs and passes a quick glance, so it gets accepted, because the bug is invisible unless you know a shallow copy shares references. That is why shallow vs deep copy matters more in 2026, not less: it is exactly the class of subtle mutation bug a machine writes confidently and a developer pastes without noticing. If you can see the shared reference, you catch it in review. If you cannot, it ships.
Keep reading
Tien Nguyen
· 8 min read
Closures in JavaScript, and the stale-closure bug in React
What a closure is in plain JavaScript, and the stale-closure bug it causes in React hooks: the one idea behind bugs AI writes and juniors paste unread.
Read article
Tien Nguyen
· 7 min read
Is JavaScript hard to learn? The parts that actually are
Is JavaScript hard to learn? Easy to start, hard to master. The hard parts are specific (the event loop, this, closures, coercion), plus the AI-era catch.
Read article
Tien Nguyen
· 9 min read
The this keyword in JavaScript, actually explained
How the this keyword in JavaScript really works: one mental model (read the call-site), the 4 binding rules, and the callback bug that trips up every dev.
Read articleLiked this? Let's build something.
I take on a small number of front end and AI-engineering projects, and I'm always up for talking shop. Tell me what you're working on.