What you'll learn
Quick Answer
Spread syntax and Object.assign create a new outer object but copy nested objects by reference, so changing a nested field changes the original as well. JSON.parse(JSON.stringify(obj)) does copy deeply, but turns Dates into strings, drops functions and undefined, converts NaN and Infinity to null, and throws on circular references. structuredClone is the built-in deep copy: it handles Dates, Maps, Sets and cycles, but rejects functions and discards class prototypes.
Assignment copies a reference, not the data
Primitives in JavaScript are copied by value. Objects, arrays, dates, maps and functions are copied by reference. So const b = a on an object creates a second name for the same thing, not a second thing.
const original = { city: 'Pune', pin: '411001' };
const alias = original;
alias.city = 'Nagpur';
original.city; // 'Nagpur' - one object, two namesEveryone learns that early. What is less obvious is that the same rule applies again one level down, and that is where copies go wrong. A shallow copy builds a new outer object and then copies each property's value into it. For a string that means a real copy. For a nested object it means copying the reference, so both the copy and the original point at the same inner object.
const student = {
name: 'Ananya',
address: { city: 'Pune', pin: '411001' },
subjects: ['Maths', 'Physics'],
};
const copy = { ...student };
copy.name = 'Ravi';
student.name; // 'Ananya' - safe, top level replaced
copy.address.city = 'Nagpur';
student.address.city; // 'Nagpur' - not safe, shared object
copy.subjects.push('Chemistry');
student.subjects; // ['Maths','Physics','Chemistry']Nothing warns you. The bug shows up much later, usually as a form that mysteriously edits a row in the list behind it, or an undo feature that undoes nothing because the saved snapshot was quietly mutated along with the live data. A deep copy means every level is recreated, so no references are shared at any depth.
Spread and Object.assign copy exactly one level
{ ...obj } and Object.assign({}, obj) do almost the same job. Both copy own enumerable properties, including symbol keys, into a fresh plain object. Both stop after one level. Neither copies the prototype, so a copied class instance becomes a plain object that has lost its methods.
class Invoice {
constructor(total) { this.total = total; }
gst() { return this.total * 0.18; }
}
const inv = new Invoice(1000);
const copy = { ...inv };
copy.total; // 1000
copy.gst; // undefined - methods live on the prototype
copy instanceof Invoice; // falseThe two differ in one way that occasionally matters. Object.assign assigns to the target, which triggers any setters the target already has. Spread defines properties directly, ignoring setters. If you are copying into an existing object with accessors, they behave differently. Both read getters on the source, so a computed getter becomes a plain frozen value in the copy.
Arrays behave identically. [...arr], arr.slice(), Array.from(arr) and arr.concat() are all shallow. An array of numbers is fully copied, an array of objects is not.
The practical fix when you know the shape is to spread each level you intend to change. This is the standard pattern for updating React state without mutating it:
setStudent((prev) => ({
...prev,
address: { ...prev.address, city: 'Surat' },
}));Verbose but explicit, and it only recreates the branch you touched, which is exactly what memoised components want to see. If your state is nested three or four levels deep, that is usually a sign to flatten the state rather than reach for a deep clone on every keystroke.
The same shallowness explains a bug that looks unrelated. You save a copy of a row before editing it so the user can cancel, the user cancels, and the old values do not come back. The snapshot shared its nested objects with the live row, so editing the form mutated the snapshot at the same time. There was never anything left to restore.
The JSON round-trip and everything it destroys
JSON.parse(JSON.stringify(obj)) is the trick everyone learns first. It really does produce a deep copy, because the object is flattened to text and rebuilt. The problem is that JSON has no way to represent most of what JavaScript objects contain, and the conversion is lossy without a single warning.
const order = {
id: 'ORD-1042',
placedAt: new Date('2026-01-14T10:30:00Z'),
total: 1299,
discount: undefined,
ratio: NaN,
recalc() { return this.total; },
};
const clone = JSON.parse(JSON.stringify(order));
typeof clone.placedAt; // 'string' - Date became text
'discount' in clone; // false - undefined dropped entirely
clone.ratio; // null - NaN and Infinity become null
clone.recalc; // undefined - functions droppedThe Date one is the most damaging because it is delayed. The clone looks fine until something calls clone.placedAt.getTime() and you get getTime is not a function, often in a different file written by a different person.
Inside arrays the rules change again: undefined and functions become null rather than disappearing, so array lengths are preserved but values are wrong. Map and Set serialise to {}, losing every entry. BigInt throws a TypeError. A circular reference throws Converting circular structure to JSON. Class instances come back as plain objects. And an object with a toJSON method, which Date itself has, is replaced by whatever that method returns.
The round-trip is acceptable for one specific case: data that came from an API as JSON in the first place, with no dates parsed and no methods attached. For anything else, use a real cloning tool.
structuredClone, the built-in deep copy
Browsers and current Node versions expose structuredClone() as a global. It uses the structured clone algorithm, the same one that sends data to a Web Worker, and it handles almost everything the JSON trick breaks.
const state = {
placedAt: new Date('2026-01-14T10:30:00Z'),
tags: new Set(['urgent', 'prepaid']),
byCity: new Map([['Pune', 12]]),
pattern: /INV-\d+/g,
items: [{ sku: 'A1', qty: 2 }],
};
state.self = state; // circular
const copy = structuredClone(state);
copy.placedAt instanceof Date; // true
copy.tags.has('urgent'); // true
copy.byCity.get('Pune'); // 12
copy.self === copy; // true - the cycle is preserved
copy.items[0] === state.items[0]; // false - genuinely deepIt also handles typed arrays, ArrayBuffer, Error objects, and in browsers Blob and File. Circular references are tracked, so a graph of objects referring to each other clones correctly instead of overflowing the stack.
What it refuses is just as important. Functions cannot be cloned, and a function-valued property makes the whole call throw a DataCloneError. The same applies to DOM nodes and symbols. So an object with a method on it fails outright, which is at least loud rather than silent.
structuredClone({ run() {} }); // DataCloneErrorThe quiet loss is prototypes. Class instances are cloned as plain objects with the same own properties, so methods vanish and instanceof returns false, exactly as with spread. Property getters are evaluated and stored as ordinary values, and non-enumerable properties are not carried across. If you need real class instances back, clone the data and reconstruct with new, or give the class a fromJSON style factory.
Choosing, and writing your own when you must
Work through it in this order. If nothing you are changing is nested, use spread, it is the cheapest and clearest option. If the data is plain JSON already received from an API, spread the branches you touch or use the JSON round-trip knowingly. If the object contains dates, maps, sets, typed arrays or cycles, use structuredClone. If it contains functions or class instances that must survive, no generic cloner will help and you should write a clone() method on the class itself, which is usually clearer anyway.
If you must support an environment without structuredClone, a small recursive clone covers the common cases. Track visited objects so cycles do not cause infinite recursion:
function deepClone(value, seen = new WeakMap()) {
if (value === null || typeof value !== 'object') return value;
if (seen.has(value)) return seen.get(value);
if (value instanceof Date) return new Date(value.getTime());
if (value instanceof RegExp) return new RegExp(value.source, value.flags);
const out = Array.isArray(value) ? [] : {};
seen.set(value, out);
for (const [key, val] of Object.entries(value)) {
out[key] = deepClone(val, seen);
}
return out;
}Note the WeakMap: it maps each original to its copy, so a repeated reference produces the same copy rather than two, and a cycle terminates.
One habit prevents most of these bugs in the first place. Treat data you did not create as read only. Build new objects instead of editing existing ones, and freeze anything shared during development with Object.freeze() so an accidental mutation throws in strict mode instead of quietly succeeding. Remember that Object.freeze is itself shallow, so nested objects stay mutable unless you freeze them too.
