Need help understanding the JavaScript spread operator

I’m struggling to understand how the JavaScript spread operator works with arrays and objects in real projects. Some examples I tried are copying references instead of creating new ones, and I’m getting unexpected mutations in my data. Can someone explain how to properly use the spread operator, with clear examples of common pitfalls and best practices for cloning and merging?

You are running into the classic “spread is shallow” problem.

Key rule:
Spread copies the top level.
Nested objects and arrays keep the same reference.

Arrays:

const a = [1, 2, { x: 10 }];
const b = […a];

b[0] = 9; // does not touch a
b[2].x = 99; // mutates a[2].x too

Reason:
Numbers are values.
The object { x: 10 } is a reference.
Spread copies that reference.

So:
– Use spread for new arrays where you change elements.
– Do not expect it to deep clone nested stuff.

Common array patterns:

// shallow copy
const copy = […arr];

// add item at end
const withNew = […arr, newItem];

// add item at start
const withFirst = [newItem, …arr];

// merge
const merged = […arr1, …arr2];

Objects:

const user = { name: ‘Ann’, meta: { role: ‘admin’ } };
const copy = { …user };

copy.name = ‘Bob’; // does not touch user
copy.meta.role = ‘user’; // changes user.meta.role too

Again, meta is shared.

Common object patterns:

// shallow copy
const copyUser = { …user };

// override some fields
const updated = { …user, name: ‘New’, age: 30 };

// add nested field safely (but still shallow)
const withFlag = { …user, active: true };

If you want safe non mutating updates for nested data, you must copy each level you touch.

Example with nested objects:

const state = {
user: {
name: ‘Ann’,
settings: { theme: ‘dark’, lang: ‘en’ }
}
};

// update theme without mutating old state
const newState = {
…state,
user: {
…state.user,
settings: {
…state.user.settings,
theme: ‘light’
}
}
};

Here:
– state is untouched.
– Every nested object on the path gets a new copy.
– Everything off that path still shares references.

If you try something like:

const newState = { …state };
newState.user.settings.theme = ‘light’;

You mutate state.user.settings.theme too, because user and settings stayed shared.

For deep cloning in small scripts you might see:

const deep = JSON.parse(JSON.stringify(obj));

But that drops functions, Date, Map, etc, so watch out.

Safer options:
– Manually spread each level you need, like the state example.
– Use a helper like structuredClone in modern browsers.
– Or a lib like lodash.cloneDeep for big nested data.

Quick checklist for you:

  1. If you see unexpected mutation, log object identity.

console.log(a[2] === b[2]); // true means shared

  1. For updates, copy each object on the path you modify.
  2. Use spread for:
    • merging props
    • non nested state updates
    • adding or removing items without mutating the original.
  3. Do not treat spread as a deep clone.

Once you think “top level only” every time you use …, the weird behavior starts to make sense.

The “spread is shallow” thing that @sognonotturno mentioned is the core issue, I’ll just come at it from a more “how do I use this in real projects without going insane?” angle.

Think of ... as:

  • For arrays: “copy this list of items into here”
  • For objects: “copy these top-level properties into here”

It is not: “make a totally independent clone of everything inside.”

Where it bites you in real-life code:

1. Updating React‑style state

Bad pattern (mutates original nested stuff):

const newState = { ...state };
newState.user.settings.theme = 'light';

Here only state itself is “new.” state.user and state.user.settings are the same old objects.

Safer pattern:

const newState = {
  ...state,
  user: {
    ...state.user,
    settings: {
      ...state.user.settings,
      theme: 'light',
    },
  },
};

You copy every level along the path you’re changing. Annoying? Yup. But predictable.

2. “Copying” configs or options

You might do:

const baseConfig = { cache: true, headers: { 'x-foo': 'bar' } };
const cfg = { ...baseConfig };

cfg.headers['x-foo'] = 'baz';  // baseConfig.headers also changed

This is exactly your “unexpected mutation” problem. If you know you’ll tweak nested stuff, you either:

  • Spread each level you care about:
const cfg = {
  ...baseConfig,
  headers: {
    ...baseConfig.headers,
    'x-foo': 'baz',
  },
};
  • Or, if you really want deep copies and can live with limitations:
const cfg = structuredClone(baseConfig);
// or JSON.parse(JSON.stringify(baseConfig)) as a hack

I slightly disagree with leaning too hard on spread for super complex nested data. At some point, manual spreading becomes a readability nightmare and a bug factory.

3. Using spread to avoid mutation in array operations

Instead of:

arr.push(item);           // mutates
arr.splice(1, 1);         // mutates

Use:

const arr2 = [...arr, item];              // push
const arr3 = [item, ...arr];              // unshift
const arr4 = [...arr.slice(0, 1), ...arr.slice(2)];  // remove index 1

This works great as long as:

  • You’re only treating elements as values or opaque references
  • You’re not then mutating the nested objects inside those arrays

If you do:

const arr = [{ id: 1 }, { id: 2 }];
const copy = [...arr];
copy[0].id = 99;

Both arr[0].id and copy[0].id become 99, because it’s the same inner object.

4. Mental model that actually sticks

When you see:

const b = [...a];

Read it as:

“Give me a new array, but for each slot, just put in whatever reference or value a had there.”

When you see:

const copy = { ...user };

Read it as:

“Make a new object, but for each top-level key, just point to the same thing the old one pointed to, unless I override it.”

That’s why this is safe:

const updated = { ...user, name: 'Alice' };

but this is not, if meta is nested:

const updatedMeta = { ...user };
updatedMeta.meta.role = 'user'; // original user.meta.role changes

5. Quick rules you can actually use

  • Need to add / remove / merge arrays or objects without touching originals? Spread is great.
  • Need to change something nested? Spread every level along the path or use a real deep-clone solution.
  • If you see a “wtf why did the old thing change” bug, log identity:
console.log(state.user === newState.user);          // false?
console.log(state.user.settings === newState.user.settings); // true?

Once you make peace with “spread only cares about the first level, everything deeper is shared unless I explicitly copy it,” the behavior stops feeling random and starts feeling just mildly annoying instead of terrifying.