Devpuff
Pricing
Log InStart Free
  1. Home
  2. Blog
  3. Shallow vs deep copy in JavaScript: how to clone properly
Learn

Shallow vs deep copy in JavaScript: how to clone properly

To copy an object in JavaScript, spread copies the top level only, so nested objects stay shared. Here is what each method keeps and destroys.

By Max Arthur
Co-Founder & Content Marketer·August 29, 2026·8 Min read
A spread copy sharing its nested object with the original beside a deep clone with its own separate nested object

Spreading an object copies the top level and shares everything below it:

const src = { name: "Ada", nested: { city: "London" } };
const copy = { ...src };

copy.nested.city = "Paris";
src.nested.city;   // "Paris"

The original changed. copy.nested and src.nested are the same object, because the spread copied the reference, not the contents.

For a genuine independent copy:

const copy = structuredClone(src);
copy.nested.city = "Paris";
src.nested.city;   // "London"

Every result on this page came from Node v24.14.1.

Why shallow copying exists at all

It is not a flaw. Objects are stored as references, so copying a property that holds an object copies the reference. Following every reference and duplicating what it points at is a different, more expensive operation, and often not what you want.

Shallow is right when the nested values are never modified, which is the common case for configuration objects and API responses you only read. It is wrong the moment anything writes through the copy.

The two shallow methods are equivalent for this purpose:

const copy = { ...src };
const copy = Object.assign({}, src);

Arrays have the same behaviour with [...arr], arr.slice() and Array.from(arr). All three copy the array and share the objects inside it.

structuredClone

Built into browsers and Node, no library needed, and it handles far more than JSON:

const clone = structuredClone(value);

Measured on an object containing awkward values, it preserved every one:

Value Survives structuredClone?
Date Yes, still a Date
Map Yes, still a Map
Set Yes, still a Set
RegExp Yes, including flags
NaN Yes
BigInt Yes

It also handles circular references, which is where most hand-written cloners break:

const o = { n: 1 };
o.self = o;

const c = structuredClone(o);
c.self === c;   // true

Not only does it not crash, it preserves the structure: the clone's self points at the clone.

The one thing it cannot do is functions:

structuredClone({ fn: () => 1 })
// DOMException: () => 1 could not be cloned.

That is by design, since it works by serialising, and a function's closure cannot be serialised. Class instances also lose their prototype and come back as plain objects, so methods do not survive either.

What the JSON trick destroys

The old standby is a round trip through JSON:

const copy = JSON.parse(JSON.stringify(src));

It is a deep copy, and it silently changes your data. Measured, here is exactly what happens:

Value After the round trip
new Date(...) a string
undefined value the key disappears
a function the key disappears
NaN null
Infinity null
Map {}, empty object
Set {}, empty object
RegExp {}, empty object
BigInt throws TypeError
circular reference throws TypeError

Only the last two announce themselves. The rest are silent, and the Date row is the one that causes the most trouble: your date becomes a string that looks fine when logged and fails the moment anything calls a date method on it.

The two throwing cases:

JSON.stringify({ big: 10n })
// TypeError: Do not know how to serialize a BigInt

const o = {}; o.self = o;
JSON.stringify(o)
// TypeError: Converting circular structure to JSON

The JSON approach is acceptable for data you know is JSON-shaped, which usually means data that arrived as JSON in the first place. For anything else, structuredClone is both safer and faster.

Choosing

Situation Use
Only reading nested values { ...src }
Flat object, no nesting { ...src }
Nested data you will modify structuredClone(src)
Data came from JSON and stays JSON either, structuredClone still preferred
Contains functions or class instances a library, or write it by hand
Very old runtime JSON round trip, knowing the losses

structuredClone is the default answer in 2026. It has been in every major browser and in Node for years, and it removes an entire category of bug that the JSON trick introduces quietly.

The partial copy, which is often what you actually want

Cloning an entire object is frequently more than the situation calls for. When you want to change one nested field, copy along the path and share the rest:

const updated = {
  ...user,
  address: { ...user.address, city: "Paris" },
};

Nothing else is duplicated, and nothing is shared that you are about to write to. This is the standard pattern for updating state in React and elsewhere, and it is why understanding shallow copying matters even when a deep clone is available.

Getting this wrong in the other direction is the more common React bug: mutating nested state directly, so the object reference never changes and the component does not re-render. The value looks updated in a log and the screen does not move.

Arrays of objects

The case that catches people who think they have solved this:

const copy = [...users];
copy[0].name = "changed";
users[0].name;   // "changed"

The array is new. The objects inside it are the same objects. So you can push and splice the copy safely, and any edit to an element is shared.

For a copy where the elements are also independent:

const copy = users.map((u) => ({ ...u }));   // one level deep
const copy = structuredClone(users);          // all the way down

The map version is the right amount of work when the objects are flat, and it makes the depth explicit, which reads better than a blanket deep clone in code that only needs one level.

Checking whether two objects are actually different

Since copies are never === to their source, "did this change" needs a field comparison. For flat objects, that is short:

const shallowEqual = (a, b) => {
  const ka = Object.keys(a);
  return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
};

For nested data, compare the fields that matter rather than reaching for a general deep-equality function. A deep comparison walks the whole structure on every call, and in most cases the question is really "did the id or the updated timestamp change", which is one comparison.

The exception is tests, where a deep equality assertion is exactly right and every test runner provides one.

Freezing, when a copy is not the goal

Sometimes the reason you are copying is to stop something being modified. Object.freeze says that directly:

const config = Object.freeze({ retries: 3 });
config.retries = 5;    // silently ignored, or throws in strict mode

It is shallow, like everything else here, so nested objects stay mutable unless you freeze them too. It is useful for module-level constants where an accidental write would be a real bug, and it makes the intent visible in a way a defensive copy does not.

Equality is about references, not contents

Worth stating outright, because it underlies all of the above:

{ a: 1 } === { a: 1 }   // false

Two objects with identical contents are different values. That is why a Set cannot deduplicate objects, why useEffect dependencies re-fire on a recreated object, and why comparing state means comparing fields rather than objects.

A copy, shallow or deep, is always a different object from its source. If you need to know whether contents match, compare the fields you care about.

Three common mistakes

Thinking a spread copied the nested data. It copied one level. Any write through copy.nested reaches the original.

Using the JSON round trip on data containing dates. They come back as strings that look right in a log and fail on the first .getTime(). This is the most common way the JSON trick causes a bug hours later rather than immediately.

Copying to avoid mutation, then mutating the copy's contents anyway. A defensive copy only defends the level it copied. If the point was to protect the caller's data, the copy has to be as deep as the writes you are about to make.

Where this bites in practice

Almost every real instance of this is state that did not appear to update.

const next = { ...state };
next.items.push(newItem);      // mutates state.items too
setState(next);

The outer object is new, so a shallow comparison sees a change and the component re-renders. But state.items and next.items are the same array, so the "before" value was already modified, and any code comparing previous to current sees no difference in the list.

The fix is to copy the path you are changing:

setState({ ...state, items: [...state.items, newItem] });

New outer object, new array, and everything else shared, which is correct because nothing else is being changed.

This is why the shallow-copy rule is worth internalising rather than defaulting to structuredClone everywhere. A full deep clone on every update copies data you are not touching, and it breaks the reference equality that frameworks rely on to skip work.

Quick reference

{ ...src }                      // shallow
Object.assign({}, src)          // shallow
[...arr]                        // shallow, arrays
structuredClone(src)            // deep, handles Date/Map/Set/circular
JSON.parse(JSON.stringify(src)) // deep, silently lossy
{ ...src, k: { ...src.k, x: 1 } }  // copy only the path you change

Want to see the shared reference bite? Start with the JavaScript track, or read removing duplicates from an array for another place object identity decides the outcome.

Keep reading

More from the blog

Two arrays combined by spread into a new array, beside push spreading into an existing one and overflowing the stack
September 1, 2026·9 min readLearn

How to merge two arrays in JavaScript

To merge arrays in JavaScript, spread and concat both work. push(...arr) throws a RangeError at 200,000 items, which is why the choice matters.

Read more
The same object iterated with Object.keys and with for-in, where for-in returns an extra inherited key
August 31, 2026·9 min readLearn

How to loop through an object in JavaScript

To loop through an object in JavaScript, for...in walks inherited properties too. And integer-like keys come out first, whatever order you wrote.

Read more
{ }
✦

Ready to write some code?

Put this into practice - start your first free lesson. No setup, no credit card.

Start learning free
Devpuff

Learn to code by doing. One tiny, playful lesson at a time.

Learn
ProgramsCoursesPricing
Company
AboutBlogResourcesAffiliates
Support
Help CenterContactStatus
Programs
Frontend DevelopmentFull-Stack DevelopmentPython Developer
Courses
Advanced ReactAsync JavaScriptAsync PythonCSS BasicsCSS LayoutDSA Basics
Learn to Code
Learn JavaScriptLearn PythonLearn SQLLearn HTMLLearn CSSLearn ReactBrowse All Topics
Platform Comparisons
Devpuff vs CodecademyDevpuff vs MimoDevpuff vs Sololearn
© 2026 Devpuff. All rights reserved.Privacy PolicyTerms and ConditionsCookies PolicyRefund Policy
Devpuff vs freeCodeCamp
Read All Comparisons