Copying Nested Objects With the JS Spread Operator

A deep dive into copying nested objects with the JavaScript spread operator. Covers shallow vs deep copy behavior, why nested references are shared, manual deep-copy patterns, structuredClone, JSON round-trip limitations, recursive cloning, and library-based solutions for production code.

JavaScriptintermediate
11 min read

The spread operator (...) is the standard tool for cloning objects and arrays in JavaScript. But it produces a shallow copy, meaning nested objects inside the copy still point to the same memory as the original. This guide explains exactly what that means and shows every approach to safely copying deeply nested data.

The Shallow Copy Problem

javascriptjavascript
const original = {
  name: "Alice",
  address: {
    city: "Portland",
    zip: "97201",
  },
  hobbies: ["reading", "hiking"],
};
 
const copy = { ...original };
 
// Top-level primitive is independent
copy.name = "Bob";
console.log(original.name); // "Alice" (safe)
 
// Nested object is shared
copy.address.city = "Seattle";
console.log(original.address.city); // "Seattle" (mutated!)
 
// Nested array is shared
copy.hobbies.push("cycling");
console.log(original.hobbies); // ["reading", "hiking", "cycling"] (mutated!)

The spread operator copies property values one level deep. If a value is a reference (object, array, function), only the reference is copied, not the data it points to.

Visualizing Shallow vs Deep Copy

Copy TypePrimitivesNested ObjectsNested Arrays
Shallow ({ ...obj })Independent copyShared referenceShared reference
Deep (structuredClone)Independent copyIndependent copyIndependent copy

Manual Nested Spread

For objects with known, limited nesting, you can spread at each level:

javascriptjavascript
const original = {
  name: "Alice",
  address: {
    city: "Portland",
    zip: "97201",
  },
  hobbies: ["reading", "hiking"],
};
 
const deepCopy = {
  ...original,
  address: { ...original.address },
  hobbies: [...original.hobbies],
};
 
deepCopy.address.city = "Seattle";
console.log(original.address.city); // "Portland" (safe)
 
deepCopy.hobbies.push("cycling");
console.log(original.hobbies); // ["reading", "hiking"] (safe)

Deeper Nesting Requires More Spreading

javascriptjavascript
const state = {
  user: {
    profile: {
      name: "Alice",
      settings: {
        theme: "dark",
        notifications: { email: true, push: false },
      },
    },
  },
};
 
// Updating notifications.push to true — immutably
const updated = {
  ...state,
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      settings: {
        ...state.user.profile.settings,
        notifications: {
          ...state.user.profile.settings.notifications,
          push: true,
        },
      },
    },
  },
};

This works but becomes verbose quickly. For state management frameworks, libraries like Immer exist specifically to solve this verbosity.

structuredClone (Modern Deep Copy)

The structuredClone() function, available in all modern environments (browsers, Node 17+, Deno, Bun), creates a true deep copy:

javascriptjavascript
const original = {
  name: "Alice",
  address: { city: "Portland" },
  scores: [90, 85],
  createdAt: new Date("2026-01-01"),
};
 
const deep = structuredClone(original);
 
deep.address.city = "Seattle";
deep.scores.push(100);
 
console.log(original.address.city); // "Portland" (safe)
console.log(original.scores);       // [90, 85] (safe)
console.log(deep.createdAt instanceof Date); // true (preserved)

What structuredClone Handles

TypeSupported?
Plain objects, arraysYes
Date, RegExp, Map, SetYes
ArrayBuffer, TypedArraysYes
Blob, File, ImageDataYes
FunctionsNo (throws)
DOM nodesNo (throws)
Symbols as valuesNo (throws)
Prototype chainNo (becomes plain object)

JSON Round-Trip

The JSON.parse(JSON.stringify(obj)) pattern was the go-to deep copy before structuredClone:

javascriptjavascript
const original = { name: "Alice", address: { city: "Portland" } };
const deep = JSON.parse(JSON.stringify(original));
 
deep.address.city = "Seattle";
console.log(original.address.city); // "Portland" (safe)

JSON Round-Trip Pitfalls

javascriptjavascript
const data = {
  date: new Date("2026-01-01"),
  pattern: /hello/gi,
  nothing: undefined,
  fn: () => "hello",
  inf: Infinity,
  nan: NaN,
  map: new Map([["a", 1]]),
};
 
const clone = JSON.parse(JSON.stringify(data));
 
console.log(typeof clone.date);    // "string" (Date becomes ISO string)
console.log(clone.pattern);        // {} (RegExp becomes empty object)
console.log("nothing" in clone);   // false (undefined properties dropped)
console.log("fn" in clone);        // false (functions dropped)
console.log(clone.inf);            // null (Infinity becomes null)
console.log(clone.nan);            // null (NaN becomes null)
console.log(clone.map);            // {} (Map becomes empty object)

Use structuredClone() instead unless you specifically need JSON serialization.

Recursive Deep Clone Function

For environments without structuredClone or for educational purposes:

javascriptjavascript
function deepClone(value) {
  if (value === null || typeof value !== "object") {
    return value;
  }
 
  if (value instanceof Date) return new Date(value.getTime());
  if (value instanceof RegExp) return new RegExp(value.source, value.flags);
  if (value instanceof Map) return new Map([...value].map(([k, v]) => [deepClone(k), deepClone(v)]));
  if (value instanceof Set) return new Set([...value].map(deepClone));
 
  if (Array.isArray(value)) {
    return value.map(deepClone);
  }
 
  const result = {};
  for (const [key, val] of Object.entries(value)) {
    result[key] = deepClone(val);
  }
  return result;
}

This handles common types but does not handle circular references. For production code, prefer structuredClone().

Immutable Update Patterns

In state management, you often need to update one nested property without mutating the original. Spread-based immutable updates are the standard:

javascriptjavascript
// State shape
const state = {
  todos: [
    { id: 1, text: "Buy milk", done: false },
    { id: 2, text: "Walk dog", done: true },
  ],
  filter: "all",
};
 
// Toggle todo with id=1 — immutably
const newState = {
  ...state,
  todos: state.todos.map(todo =>
    todo.id === 1 ? { ...todo, done: !todo.done } : todo
  ),
};

Each level that changes gets a new spread copy. Levels that do not change keep their references, which is efficient for equality checks.

When to Use Each Approach

ScenarioBest Approach
One-level flat object{ ...obj }
Known shallow nesting (2-3 levels)Manual nested spread
Arbitrary or unknown depthstructuredClone()
Serializable data onlyJSON.parse(JSON.stringify())
State management updatesSpread + map (or Immer)
Legacy environments (no structuredClone)Recursive function or lodash cloneDeep
Rune AI

Rune AI

Key Insights

  • Spread creates shallow copies only: Nested objects and arrays are shared by reference between original and copy
  • Manual nested spread works for known shapes: Spread at each level that contains objects, but it becomes verbose past 2-3 levels
  • structuredClone is the modern deep copy: Handles Date, Map, Set, circular references, and arbitrary depth without gotchas
  • JSON round-trip loses type information: Dates become strings, functions are dropped, undefined is stripped, and special numbers become null
  • Immutable updates use spread plus map: Each changed level gets a new spread copy while unchanged references are preserved for efficient equality checks
RunePowered by Rune AI

Frequently Asked Questions

Does spread copy Symbol-keyed properties?

Yes. Object spread copies own enumerable properties, including those keyed by Symbols. `Object.assign()` does the same.

Is structuredClone slower than spread?

For shallow objects, yes. `structuredClone` does full traversal regardless of depth. For simple flat objects, spread is faster. Use `structuredClone` only when you need true deep copies.

Can I deep-copy an object with circular references?

`structuredClone()` handles circular references correctly. JSON round-trip throws a TypeError. Manual recursive cloning requires a `seen` WeakSet to track visited objects.

Does spread preserve getters and setters?

No. Spread invokes getters and copies the returned value. The getter/setter descriptors themselves are not transferred. Use `Object.defineProperties()` with `Object.getOwnPropertyDescriptors()` to copy accessors.

What about frozen or sealed objects?

Spread does not preserve `Object.freeze()` or `Object.seal()` status. The copy is a plain, mutable object. Reapply `Object.freeze()` after copying if needed.

Conclusion

The spread operator creates shallow copies only. Nested objects and arrays remain shared between original and copy. For most modern code, use structuredClone() for true deep copies. For immutable state updates in frameworks, manual nested spread is the standard convention. For more on the spread vs rest distinction, see JS spread vs rest operator complete tutorial. For related variable extraction techniques, see advanced array and object destructuring guide.