Section

Objects & Arrays

Priority

Low

Difficulty

Easy

Duration

5 min

Explain the difference between shallow copy and deep copy

A shallow copy duplicates the top-level properties of an object, but nested objects are still referenced. A deep copy duplicates all levels of an object, creating entirely new instances of nested objects. For example, using `Object.assign()` creates a shallow copy, while using libraries like `Lodash` or `structuredClone()` in modern JavaScript can create deep copies.

Javascript

TL;DR

A shallow copy duplicates the top-level properties of an object, but nested objects are still referenced. A deep copy duplicates all levels of an object, creating entirely new instances of nested objects. Object.assign() and the spread operator (...) create shallow copies. structuredClone() is the modern built-in for deep copies. JSON.parse(JSON.stringify()) and Lodash's _.cloneDeep are other common approaches, each with different tradeoffs around which values they can faithfully clone.

// Shallow copy — nested object is shared
let obj1 = { a: 1, b: { c: 2 } };
let shallowCopy = { ...obj1 };
shallowCopy.b.c = 3;
console.log(obj1.b.c); // 3 — original mutated too

// Deep copy — fully independent
let obj2 = { a: 1, b: { c: 2 } };
let deepCopy = structuredClone(obj2);
deepCopy.b.c = 4;
console.log(obj2.b.c); // 2 — original unchanged

Difference between shallow copy and deep copy

Shallow copy

A shallow copy creates a new object and copies the values of the original object's top-level properties into the new object. However, if any of these properties are references to other objects, only the reference is copied, not the actual object. This means that changes to nested objects in the copied object will affect the original object.

let obj1 = { a: 1, b: { c: 2 } };
let shallowCopy = Object.assign({}, obj1);
shallowCopy.b.c = 3;
console.log(shallowCopy.b.c); // 3
console.log(obj1.b.c); // 3 — original nested object changed too

shallowCopy is a new object, but shallowCopy.b and obj1.b point to the same nested object, so mutating shallowCopy.b.c is visible through obj1.b.c.

Deep copy

A deep copy recursively duplicates the original object and every object it references, producing a fully independent tree. Mutations to the clone — including to its nested objects — have no effect on the original.

let obj2 = { a: 1, b: { c: 2 } };
let deepCopy = structuredClone(obj2);
deepCopy.b.c = 4;
console.log(deepCopy.b.c); // 4
console.log(obj2.b.c); // 2 — original nested object unchanged

structuredClone recursively copies obj2 and its nested b object, so deepCopy.b is a fresh object independent of obj2.b.

Comparison of common cloning methods

The table below summarizes the behavior of the four most common approaches.

Value / scenario{ ...obj } / Object.assignJSON.parse(JSON.stringify())structuredClone()Lodash _.cloneDeep
Nested objects / arraysShared (shallow)ClonedClonedCloned
DateShared referenceBecomes ISO stringCloned as DateCloned as Date
undefined valuesPreservedDroppedPreservedPreserved
NaN, Infinity, -InfinityPreservedBecomes nullPreservedPreserved
Circular referencesNot recursed; references sharedThrowsSupportedSupported
Map, SetShared referenceBecomes {}ClonedCloned
RegExpShared referenceBecomes {}ClonedCloned
Functions / methodsShared referenceDroppedThrowsShared reference
Symbol keysPreservedDroppedNot clonedCloned
Class instances (prototype)Becomes plain objectBecomes plain objectBecomes plain objectPrototype preserved
BigIntPreservedThrows on stringifyPreservedPreserved
  • Shallow copy does not recurse into nested objects. For a cyclic input, the shallow copy does not throw, but copy.self still points to the original object rather than to the clone, so the cycle is not preserved as a cycle in the new object.
  • JSON.parse(JSON.stringify()) does not preserve Date, undefined, NaN, Map, Set, RegExp, or functions. It throws on circular references and BigInt.
  • structuredClone() clones string-keyed own enumerable data properties, including Date, Map, Set, RegExp, typed arrays, ArrayBuffer, and cycles. It throws on function and symbol values. Symbol-keyed properties and the prototype chain are not preserved in practice, so the result is a plain object.
  • DOM nodes are intentionally omitted from the table. Behavior depends on the specific node type, the host environment, and the tooling involved; a general-purpose cell in a summary table would be misleading.

Which method should I reach for?

Work through the questions below in order and stop at the first one that applies.

  1. Do only the top-level properties need to be independent? Use a shallow copy — spread ({ ...obj }, [...arr]), Object.assign, Array.from, or Array.prototype.slice. This is cheaper than any deep-copy method and is sufficient when nested objects are either immutable or not being mutated.
  2. Does the object contain functions, methods, or class instances whose prototype must be preserved? Use Lodash's _.cloneDeep. It is a widely-used option that copies function-valued properties by reference and keeps the prototype chain of class instances intact; structuredClone() throws DataCloneError on function- or symbol-valued properties, and returns plain objects for class instances. Two caveats: _.cloneDeep(fn) returns {} when the top-level argument is itself a function (this branch only helps when the function lives inside the object being cloned), and neither method re-runs constructors, so any invariants enforced only at construction time are not reapplied to the clone.
  3. Does the object contain Date, Map, Set, RegExp, typed arrays, ArrayBuffer, or circular references? Use structuredClone(). It is built in, dependency-free, and handles all of these natively; JSON.parse(JSON.stringify()) silently loses or throws on each of them.
  4. Is every value strictly JSON-safe (strings, finite numbers, booleans, null, plain objects and arrays — no undefined, NaN/Infinity, functions, symbols, or BigInt)? Either structuredClone() or JSON.parse(JSON.stringify(obj)) works. Prefer structuredClone() as a default — it also handles Date, Map, Set, RegExp, typed arrays, and cycles if those later appear in the object, though it will still throw on functions and symbols.

In short: structuredClone() is the default deep-copy in modern JavaScript. Reach for _.cloneDeep when functions or prototypes matter, and reserve JSON.parse(JSON.stringify()) for short scripts where the input is known to be JSON-safe.

Edge cases worth knowing

Shallow copy only duplicates the top level

const user = { name: 'Ada', address: { city: 'London' } };
const copy = { ...user };

copy.name = 'Grace';
copy.address.city = 'Paris';

console.log(user.name); // 'Ada'
console.log(user.address.city); // 'Paris'

name is a primitive, so assigning to copy.name does not affect user.name. address is an object, and the spread operator copies the reference — copy.address and user.address point to the same object, so mutating one is visible through the other.

JSON.parse(JSON.stringify()) data loss

const data = {
  createdAt: new Date('2026-01-01'),
  score: NaN,
  note: undefined,
  tags: new Set(['a', 'b']),
};

const cloned = JSON.parse(JSON.stringify(data));
console.log(cloned);
// {
//   createdAt: '2026-01-01T00:00:00.000Z', // Date serialized as ISO string
//   score: null,                            // NaN serialized as null
//   tags: {}                                // Set has no enumerable own properties
// }
// `note` is absent — undefined values are omitted by JSON.stringify.

JSON.stringify calls toJSON() (on Date) or returns null (on NaN, Infinity), and skips undefined values and function-valued properties entirely. Map and Set have no enumerable own properties, so they serialize to {}.

structuredClone() and circular references

const a = { name: 'a' };
a.self = a;

const cloned = structuredClone(a);
console.log(cloned.self === cloned); // true
console.log(cloned.self === a); // false

structuredClone() tracks visited references during cloning and reconstructs cycles in the output. JSON.parse(JSON.stringify()) throws TypeError: Converting circular structure to JSON on the same input.

structuredClone() drops the prototype chain

class User {
  constructor(name) {
    this.name = name;
  }
  greet() {
    return `Hi, ${this.name}`;
  }
}

const u = new User('Ada');
const cloned = structuredClone(u);

console.log(cloned instanceof User); // false
console.log(cloned.greet); // undefined

The structured clone algorithm copies own data properties only; the result has Object.prototype as its prototype. To clone class instances with methods and instanceof intact, use Lodash's _.cloneDeep, or define an explicit clone() method on the class.

Shallow copies and React state updates

React compares next and previous state with Object.is and bails out of re-rendering if the reference is unchanged. Mutating a nested value and calling the setter with the same top-level reference therefore has no effect on the rendered output.

function UserProfile() {
  const [user, setUser] = useState({
    name: 'Ada',
    preferences: { theme: 'dark' },
  });

  const toggleTheme = () => {
    // Mutates the existing nested object and passes the same top-level reference.
    user.preferences.theme =
      user.preferences.theme === 'dark' ? 'light' : 'dark';
    setUser(user); // Object.is(user, user) === true — React bails out.
  };

  return <button onClick={toggleTheme}>{user.preferences.theme}</button>;
}

Spreading the top level while still mutating the nested object is also incorrect:

const toggleTheme = () => {
  user.preferences.theme = user.preferences.theme === 'dark' ? 'light' : 'dark';
  // `user.preferences` was mutated in place on the line above. Any previous
  // snapshot, memoized selector, or component still holding the old reference
  // now observes the toggled value.
  setUser({ ...user, preferences: { ...user.preferences } });
};

The correct pattern computes the new value while constructing the new object tree, without mutation:

setUser((prev) => ({
  ...prev,
  preferences: {
    ...prev.preferences,
    theme: prev.preferences.theme === 'dark' ? 'light' : 'dark',
  },
}));

Manual spread chains become error-prone past two levels of nesting. Libraries such as Immer (used internally by Redux Toolkit) let the developer write mutation-style code against a draft, which the library converts into an immutable update.

Follow-up questions

Why can't JSON.parse(JSON.stringify()) round-trip Date objects?

JSON.stringify invokes Date.prototype.toJSON, which returns the ISO string form of the date. The resulting JSON contains no type information, so JSON.parse returns a plain string. Any subsequent call such as .getTime() or a date-formatting operation on the parsed value will throw TypeError, because the value is a string, not a Date. This is a common source of bugs in flows that serialize state to localStorage and rehydrate it on load.

How can an object containing a Map be deep-cloned?

structuredClone(obj) handles Map natively and is the default in any modern environment. Lodash's _.cloneDeep is the fallback when the object also contains class instances or functions, or when running on a runtime that predates structuredClone. JSON.parse(JSON.stringify()) is not viable — Map entries are not enumerable own properties, so a Map serializes to {}.

When should _.cloneDeep be used instead of structuredClone?

The two main cases — class instances whose prototype must survive, and objects with function-valued properties — are covered in the decision guide above. The third case specific to Lodash is custom per-type cloning: _.cloneDeepWith(obj, customizer) takes a callback that intercepts specific types, useful for framework-specific objects or domain models with non-enumerable state.

Is the spread operator always a shallow copy?

Yes. The spread operator copies own enumerable properties one level deep. When spreading a class instance, inherited methods on the prototype are not included in the result, and the output is a plain object rather than an instance of the original class.

Further reading