Ch. 1 · JavaScript

JavaScript Array Copies and Shared Objects

Why [...arr] and slice() copy the array but not the objects inside it, and how to choose a targeted update, structuredClone or JSON safely.

~7 min readbeginnerupdated Oct 19, 2026

Ask a room of JavaScript developers whether const copy = [...cart] copies the cart, and most will say yes. They’re half right. You get a new array, so pushing to copy leaves cart alone. But the objects inside are the same objects, and that half of the answer is where the bugs live: a “copied” list in a React reducer that mutates the previous state, a test fixture that slowly changes between test cases, an undo history where every entry shows the latest edit.

This guide separates the two halves cleanly, shows you how to update one nested value without copying everything, and compares the two deep-copy tools people reach for (structuredClone and the JSON round trip) on the values where they differ.

What a spread copy actually copies

An array holds values. For numbers, strings and booleans the value is the data itself. For objects and arrays the value is a reference, a pointer to something living elsewhere on the heap. When you spread an array, JavaScript walks it and copies each value into a fresh array. Copying a reference gives you a second pointer to the same object, not a second object.

Here’s a shopping cart with two line items:

const cart = [
  { sku: 'A1', qty: 1, tags: ['sale'] },
  { sku: 'B2', qty: 3, tags: [] },
];
const copy = [...cart];

copy[0].qty = 5;
console.log(cart[0].qty);                         // 5
console.log(copy === cart, copy[0] === cart[0]);  // false true

copy.push({ sku: 'C3', qty: 1, tags: [] });
console.log(cart.length, copy.length);            // 2 3
JavaScript

Changing qty through copy[0] changed the cart, because copy[0] and cart[0] are one object. Pushing a third item didn’t, because push changes the array itself and the arrays are different. That’s the whole rule: operations on the container are independent, operations on the elements are shared.

The same rule applies to cart.slice(), Array.from(cart), cart.concat() and Object.assign([], cart). All four are shallow: cart.slice()[0] === cart[0] and Array.from(cart)[0] === cart[0] both print true.

Replacing an element versus editing it

Watch what happens when you assign a new object into the slot instead of editing the existing one:

copy[1] = { ...copy[1], qty: 4 };
console.log(cart[1].qty, copy[1].qty);  // 3 4

copy[1].tags.push('gift');
console.log(cart[1].tags);              // [ 'gift' ]
JavaScript

The first line is safe. Writing to copy[1] puts a new reference in the copy’s slot and leaves the cart’s slot pointing at the old object. The second line bites again. The object spread { ...copy[1] } is shallow too, so the new line item still shares its tags array with the original. Spreading one level fixes one level.

Interviewers like this two-step version because it tests whether you understand the mechanism or memorised “spread makes a copy”. If you can explain why the first line is safe and the second isn’t, you’ve shown the mechanism.

Two traps that look like copies but share everything

Nested arrays behave identically. Spreading a grid gives you a new outer array whose rows are the original rows:

const grid = [[0, 0], [0, 0]];
const g2 = [...grid];
g2[0][0] = 1;
console.log(grid[0][0]);  // 1
JavaScript

And Array(n).fill(value) puts the same reference in every slot, so Array(2).fill([]) gives you one array listed twice. Push into the first row and both rows show ['x']. Use Array.from({ length: 2 }, () => []) when each slot needs its own array.

Copying only the path you change

Most of the time you don’t want a deep copy. You want an updated version of the data where the changed parts are new and everything else is shared. This pattern, often called structural sharing, is what Redux reducers, React state updates and immutable libraries are built on.

Say a customer adds a gift note to line item B2, and the note lives one level down in meta:

const before = [
  { sku: 'A1', qty: 1, meta: { note: '' } },
  { sku: 'B2', qty: 3, meta: { note: '' } },
];

const after = before.map(item =>
  item.sku === 'B2'
    ? { ...item, meta: { ...item.meta, note: 'gift wrap' } }
    : item
);

console.log(before[1].meta.note === '', after[1].meta.note);  // true gift wrap
console.log(after[0] === before[0]);            // true
console.log(after[1] === before[1]);            // false
console.log(after[1].meta === before[1].meta);  // false
JavaScript

Read the three identity checks as a map of what changed. map built a new outer array. Item A1 didn’t change, so the callback returned the same object, and after[0] === before[0] is true. Item B2 is on the path to the change, so it got a new object, and so did its meta. Every object between the root and the changed field is new; every object off that path is reused.

This is cheaper than a deep copy, and it gives you something a deep copy throws away: identity as a change signal. React.memo, useMemo dependencies and Redux selectors all compare with ===. After a structural-sharing update, the row component for A1 sees the same object and skips rendering. After a deep copy, every row sees a new object and re-renders even though nothing about it changed.

Interview tip

When the path gets three or four levels deep, the nested spreads become hard to read. That’s the point where a helper such as Immer’s produce earns its place: you write what looks like a mutation, and it produces the structural-sharing copy for you.

structuredClone versus the JSON round trip

Sometimes you really do need a fully independent snapshot: an undo stack, a payload you’re about to hand to code you don’t trust, a fixture each test should get fresh. There are two common ways to do it, and they disagree on a lot of values.

Here’s what each does to the same order object:

const order = {
  placed: new Date('2026-10-19T09:30:00Z'),
  total: NaN,
  skus: new Set(['A1']),
  note: undefined,
  fmt() {},
};

console.log(JSON.parse(JSON.stringify(order)));
// { placed: '2026-10-19T09:30:00.000Z', total: null, skus: {} }
JavaScript

The JSON round trip didn’t throw, which is the dangerous part. The Date came back as a string, so placed.getTime() will throw a TypeError later, somewhere far away. NaN became null. The Set became an empty object and its contents vanished. note and fmt were dropped entirely. Add a circular reference and JSON.stringify throws TypeError: Converting circular structure to JSON.

structuredClone, available in browsers and in Node since version 17, uses the same algorithm the browser uses for postMessage:

const snap = structuredClone({
  placed: new Date('2026-10-19T09:30:00Z'),
  skus: new Set(['A1']),
  total: NaN,
});
console.log(snap.placed instanceof Date, snap.skus instanceof Set, snap.total);
// true true NaN

try { structuredClone({ fmt() {} }); }
catch (e) { console.log(e.name + ': ' + e.message); }
// DataCloneError: fmt() {} could not be cloned.
JavaScript

Dates, Sets, Maps, typed arrays, NaN and circular references all survive. A self-referencing object comes back with clone.self === clone. Functions make it throw a DataCloneError instead of silently dropping data, which is the behaviour you want.

There’s one surprise left. Class instances lose their class:

class Item { constructor(sku) { this.sku = sku; } label() { return this.sku; } }
const c = structuredClone(new Item('A1'));
console.log(c instanceof Item, c);  // false { sku: 'A1' }
JavaScript

The own data properties are copied into a plain object, and the prototype (with label) is gone. If your state is made of class instances, give the class its own clone() method rather than relying on either tool.

Value JSON.parse(JSON.stringify(x)) structuredClone(x)
Date becomes an ISO string stays a Date
NaN, Infinity becomes null preserved
Map, Set becomes {} (data lost) preserved
undefined property dropped preserved
function dropped silently throws DataCloneError
circular reference throws TypeError preserved
class instance plain object plain object

Mistakes interviewers listen for

The most common one is calling spread a “deep copy” or saying “spread clones the array” without qualification. Say “spread creates a new array that shares its elements” and you’ve avoided it.

A second is reaching for a deep copy by reflex. Deep-cloning a 5,000-row table on every keystroke costs time and memory, and it defeats memoisation. If the question is about updating state, the expected answer is a targeted update.

A third is recommending JSON.parse(JSON.stringify(x)) without mentioning what it loses. It’s fine for plain data that came from JSON in the first place. Outside that, you should name at least the Date problem.

Finally, people forget that const doesn’t help here. const cart = [...] stops you from reassigning cart; it does nothing to stop cart[0].qty = 5. Object.freeze is also shallow, so freezing the outer array still leaves the line items writable.

Interview question: update one item without touching the original

Question. You have const users = [{ id: 1, profile: { city: 'Pune' } }, { id: 2, profile: { city: 'Lyon' } }]. Write a function moveUser(users, id, city) that returns a new array where that user’s city is updated, the input is unchanged, and users who didn’t move keep their identity. What does it return when the id isn’t found?

Answer. Map over the array and rebuild only the object on the path:

function moveUser(users, id, city) {
  let found = false;
  const next = users.map(u => {
    if (u.id !== id) return u;
    found = true;
    return { ...u, profile: { ...u.profile, city } };
  });
  return found ? next : users;
}
JavaScript

The matching user gets a new outer object and a new profile, because both sit between the array and the changed field. Every other user is returned as-is, so next[1] === users[1] holds and a memoised row for that user won’t re-render. When the id isn’t found, returning the original users reference tells callers “nothing changed” with a cheap === check; returning next would hand back an equal but distinct array and trigger needless work. Either policy is defensible as long as you choose it on purpose and say why. If the interviewer pushes on structuredClone(users) as an alternative, point out that it’s correct but copies every user, losing the identity signal the question asked you to keep.

Keep going

More in JavaScript

read ✓JavaScript · easy

JavaScript Array Sort Comparators

Sort arrays in place with a correct comparator, understand default string ordering and sort stability, and avoid the numeric and string traps.

~3 min readread →
esc