Modelling real data without a browser · Lesson 58 of 77 · practice

Changing data without breaking it

About 60 minutes.

By the end of this lesson you can

  • Explain what it means for two names to point at the same object.
  • Produce a changed copy of an object or array without touching the original.
  • Say what shallow means, and predict where a shared nested value will bite.
  • Name which common array methods change the array and which return a new one.

Two names can point at one object

Explanation

When you write const b = a and a holds a number, b gets its own copy of that number and the two go their separate ways forever. When a holds an object, something different happens: b does not get a copy of the object. It gets a second name for the same object.

That is not a quirk to be worked around; it is how every language of this kind works, and it is what makes it possible to pass a large record to a function without copying it. But it does mean that a change made through one name is visible through the other, immediately, and that surprises everyone the first time.

It also explains something that looked odd in lesson 3: the lookup table you built holds the same record objects as the array it came from. Two views, one set of objects. Editing through the table edits what the array sees, because there was only ever one thing there.

const does not help here. const stops the NAME from being pointed at something else; it says nothing at all about whether the thing itself can be changed. A const object is fully editable, and a great many people lose an afternoon to that once.

A shared whiteboard, not a photocopy

An analogy

Think of an object as a whiteboard in a shared room. Handing someone the object is telling them where the room is. It is not handing them a photocopy.

So when they rub something out, everyone who knows where the room is sees it gone — including you, and including the part of your program that had no idea anyone else was writing on it.

Copying is deliberate: you go to the room and photograph the board. Now you have your own picture and can scribble on it freely. The catch, and it is the important one, is that if the board had a note saying "see the other room for the details", your photograph has that note too. It does not have a copy of the other room.

Copy, then change: the spread pattern

Explanation

The standard move is to build a new object that starts as a copy of the old one and then overrides the parts you want different. { ...user, name: "Grace" } means: take every property of user, then set name to Grace, and make that a brand new object.

Order inside the braces decides who wins, and later wins. { ...user, name: "Grace" } overrides user's name; { name: "Grace", ...user } is overridden BY user and does nothing useful. This is worth burning in, because both spellings look reasonable at a glance.

A property that already existed keeps its original position when you override it, so the field order of your records stays stable. A property that did not exist is appended at the end. That predictability is what makes records readable in logs and diffs.

Arrays have the same pattern: [...list, newItem] to append, [...list.slice(0, i), newValue, ...list.slice(i + 1)] to replace one position, and list.filter(...) to remove. All of them return a new array and leave the original alone.

Shallow: the copy stops at the first level

A common mistake

Spread copies one level deep and no further. If a property holds another object, the copy gets the same pointer to that same nested object — not a copy of it.

So { ...user } gives you a new user you can safely rename, and a nested address that is still shared with the original. Change copy.address.city and the original's city changes too, because there is exactly one address object and both users are pointing at it.

This is the single most common source of "I copied it and it still changed" confusion. The copy did work. It just did not go as deep as you assumed.

The fixes, in order of preference: spread the nested part too, { ...user, address: { ...user.address, city: "Paris" } }, which is explicit about exactly what is being replaced; or, when the data is plain and you genuinely want everything deep-copied, use the JSON round trip from lesson 2 with its losses in mind (no undefined, no Dates, no Maps). Browsers also offer structuredClone for this, though it is not available in this page's sandbox.

Which array methods change the array

A mental model

There is no way to deduce this from the names, so it is worth learning as a list. These MUTATE the array they are called on: push, pop, shift, unshift, splice, sort, reverse, fill and copyWithin.

These return a new array and leave the original alone: map, filter, slice, concat, flat, flatMap, and spreading with [...]. Newer engines add toSorted, toReversed, toSpliced and with, which are the non-mutating twins of the first list.

sort and reverse are the two that catch people, because they also RETURN the array, so const sorted = scores.sort() looks like it made a copy. It did not: sorted and scores are the same array, and scores is now in a different order. Write scores.slice().sort(...) — or toSorted(...) — when the original must survive.

One more sort surprise, unrelated to mutation: with no compare function, sort converts every element to a string and sorts by text. That puts 10 before 9. Numbers need an explicit comparison, sort((a, b) => a - b), every single time.

Why this discipline is worth the extra characters

Why it matters

A function that changes what it was given has an effect nobody reading the call can see. update(basket) tells you nothing; const next = withQuantity(basket, sku, 2) tells you everything, including that the old basket is still there if you need it.

It also makes comparison possible. If updates always produce a new object, then oldValue !== newValue is a reliable, instant test of whether anything changed — which is precisely how user-interface libraries decide what to redraw, and why this style is so common in modern JavaScript.

And it makes bugs local. When data can only be changed by producing a new version, the list of places that could have caused a wrong value is the list of places that built one — not every line in the program that ever held a reference to it.

The cost is real but small: a little more memory and a few more characters. Pay it by default for shared data, and mutate freely inside a function on values that function created itself, where nobody else can see.

Recap

Recap

Assigning an object gives a second name for one thing, and const protects the name rather than the contents. Copy-then-override with spread produces a changed copy; later entries win, and existing keys keep their position. Spread is shallow, so nested objects stay shared unless you spread them too. push, splice, sort and reverse change the array in place; map, filter, slice and concat return a new one.

Worked examples

Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.

A second name, and then a real copy

The first half shows a change made through one name appearing through the other. The second half shows what a copy actually buys you.

1const original = { name: "Ada", tags: ["x"] };2 3const alias = original;4alias.name = "Grace";5console.log(original.name);6 7const copy = { ...original };8copy.name = "Alan";9console.log(original.name);10console.log(copy.name);11console.log(copy === original);
  • line 3

    No copying happens on this line. alias is a second name for the object original already names.

  • line 5

    Grace, not Ada. The change was made through alias and is visible through original, because there is one object.

  • line 7

    Spread builds a NEW object holding the same properties. This is the line that actually copies.

  • line 9

    Still Grace. The rename on the previous line went into the copy, which original knows nothing about.

  • line 11

    false — proof they are different objects. Compare this with the alias, where === would have said true.

What it prints

GraceGraceAlanfalse

The copy that was not deep enough

A correct spread, a correct override, and a nested change that leaks straight back into the original. Read the first output line twice: that is the bug people spend hours on.

1const user = { name: "Ada", address: { city: "London" } };2 3const shallow = { ...user, name: "Grace" };4shallow.address.city = "Paris";5 6console.log(user.address.city);7console.log(user.name);8console.log(JSON.stringify(shallow));9 10const deep = { ...user, address: { ...user.address, city: "Rome" } };11console.log(user.address.city);12console.log(deep.address.city);
  • line 3

    A new top-level object with name overridden. Everything here is working exactly as intended.

  • line 4

    But address was copied as a pointer, not as an object, so this writes into the address that user is also holding.

  • line 6

    Paris. The original was changed by a line that only ever mentioned shallow. This is the whole lesson in one output line.

  • line 7

    Ada, though — the name override did NOT leak, because name is a string one level down and strings are copied by value.

  • line 10

    The fix: spread the nested object too, so the new user gets a new address. Explicit, and it says exactly which level is being replaced.

  • line 11

    Still Paris — from the earlier leak, not from this line. The deep copy left the original completely alone.

What it prints

ParisAda{"name":"Grace","address":{"city":"Paris"}}ParisRome

sort changes the array, and sorts as text

Two separate surprises in one method. The first is that it does not make a copy even though it returns one. The second is that its default order is alphabetical, not numeric.

1const scores = [3, 1, 2];2const sorted = scores.sort();3console.log(JSON.stringify(scores));4console.log(sorted === scores);5 6const safe = [3, 1, 2];7const safelySorted = safe.slice().sort((a, b) => a - b);8console.log(JSON.stringify(safe));9console.log(JSON.stringify(safelySorted));10 11console.log(JSON.stringify([10, 9, 1].sort()));12console.log(JSON.stringify([10, 9, 1].sort((a, b) => a - b)));
  • line 3

    scores itself is now sorted. Nothing asked for that; sort rearranged the array it was called on.

  • line 4

    true — sorted is not a copy, it is the same array under a second name. The assignment looked like a copy and was not.

  • line 7

    slice() with no arguments returns a copy of the whole array. Sorting the copy leaves the original in peace.

  • line 11

    The default order converts each value to text and compares the text, and "10" sorts before "9" because 1 comes before 9.

  • line 12

    A compare function fixes it. a - b is negative when a should come first, which is the whole contract sort expects.

What it prints

[1,2,3]true[3,1,2][1,2,3][1,10,9][1,9,10]

Check yourself

Not scored, not stored. Getting one wrong is the useful part.

One object, one alias and one spread copy. Each of the last two lines changes something. What are the three counts at the end?

const a = { count: 1 };const b = a;const c = { ...a };b.count = 2;c.count = 3;console.log(a.count, b.count, c.count);

Which of these leaves the original array unchanged?

Given const copy = { ...user } where user.address is an object, what does copy.address.city = "Paris" do?

Write it yourself

Exercise · core · 7 tests

Change one line of a basket, safely

A basket is an array of lines, each { sku, quantity }. Write withQuantity(basket, sku, quantity) that returns a NEW basket in which the line with the matching sku has the given quantity, and everything else is as it was. The basket you were handed, and the line objects inside it, must be exactly as they were when the function returns. If no line matches, return a new array with the same contents.

What your code must do

Returns a new array, never the one passed in, with the matching line's quantity replaced and its properties in their original order (sku then quantity). Non-matching lines are unchanged. An unknown sku gives an equal copy. An empty basket gives an empty array. The original basket array and every line object in it are untouched, including their quantity values. The tests compare JSON text, so key order matters.

Define withQuantity at the top level of your code — the tests call it by name.

Runs on your device in a sandbox with no network and no access to this page.

Worth remembering

  • Does const stop an object being changed?

    No. const stops the name being pointed at something else. The object it names is fully editable, and a second name for it sees every change.

  • What does it mean that spread is shallow?

    It copies one level. Nested objects are shared with the original, so copy.address.city = ... also changes the original's city. Spread the nested level too, or deep-copy deliberately.

  • Which common array methods change the array in place?

    push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin. Safe ones: map, filter, slice, concat, flat, and spreading. sort and reverse also return the same array, which disguises the mutation.

Check this lesson against the source

We wrote the explanation; we did not invent the facts. This is the page that backs them.

MDN — Spread syntax (...)

Check there: The note that spread syntax makes a shallow copy: it copies one level deep, so nested objects are shared with the source rather than duplicated.

MDN — Array.prototype.sort()

Check there: That sort sorts the array in place and returns that same array, and that without a compare function elements are converted to strings and compared in UTF-16 order.

MDN — Array.prototype.toSorted()

Check there: That toSorted returns a new sorted array and leaves the original unchanged, unlike sort.

Prefer to read the source in a structured breakdown? Turn this doc into a breakdown

0 of 1 exercise in this lesson solved on this device

Consent version 2026-07-31.1

Cookie preferences