01 / The idea
A spread copy is a fair start for a draft.
You’re building the address form for a shipping dashboard. Opening it makes a draft with a
spread, { ...saved }, so the form can change things without touching the
saved shipment. Change the recipient and the saved recipient stays Mina Patel. For a flat
record, that really is a copy.
Read the first draftTypeScript · the version this lesson starts from
// The first draft: a new shipment object, so editing the recipient leaves the saved one alone.
export function shallowDraft(saved: Shipment): Shipment {
return { ...saved };
} Go’s version copies the shipment struct, whose address field is a pointer. Both languages
meet again at independentDraft in section 02.
Then someone changes the city. The saved shipment says Salem too. The unsaved-changes check compares the draft with the saved shipment, finds them equal, and lets the form close without asking. Cancel drops the draft, and the saved city stays changed.
A spread copies one object. The address is a separate object that both copies point at, so a write to it is shared. Identity asks whether two names reach the same object; equality asks whether two objects match by a rule you choose. And a change can only be detected against something that didn’t change. React’s documentation says the first part bluntly: “When the code executes, there is no such thing as a ‘nested’ object.”
Section 05 builds an address editor with a real unsaved-changes warning, in React and Svelte.
02 / See the shape
Copy what you edit, and name what “same” means.
The basic form is the copy boundary: copy every object the form can edit. In the wild names three kinds of sameness and checks for unsaved changes against a baseline. At the call site edits the city in each kind of draft, then loads the same shipment twice.
Both languages print the same three lines.
The copy boundary. Copy every object the form can edit: here, the shipment and its address.
// Copy every object the form can edit. For this shipment, that's the shipment and its address.
export function independentDraft(saved: Shipment): Shipment {
return { ...saved, address: { ...saved.address } };
} // IndependentDraft copies every struct the form can edit: the shipment and its address.
func IndependentDraft(saved *Shipment) *Shipment {
draft := *saved
address := *saved.Address
draft.Address = &address
return &draft
} Reading the TypeScriptSpreads, ===, and structuredClone
{ ...saved } copies the shipment’s own properties, and the address property is copied as a reference. The second spread in independentDraft makes a new address.
=== on objects asks whether they’re the same object. MDN is explicit that
for “distinct objects” with “the same structure”, every equality operator evaluates to
false. structuredClone makes a deep clone, has worked across browsers since
March 2022, and throws a DataCloneError for anything it can’t serialize.
Reading the GoPointers compare by identity, structs by field
Pointer values are equal when they point to the same variable. Struct values are equal
when their fields are, so *a == *b compares the two address pointers and is false
for an independent draft with matching details.
SameDetails dereferences the address and compares its strings. A copied
struct copies its pointer field, which is why ShallowDraft shares the address.
03 / Follow the draft
Watch what each draft shares.
Five steps, each running the lesson’s functions on real objects. Names point at lettered
objects, and each shipment card shows which address it points at. Letters mark identity in
the order objects appear, not memory addresses. Before each step, guess what saved says.
In Try it, open a draft either way, edit it, cancel, and reopen.
What a draft copies, and what it still shares.
A shallow draft
Line 1 of 2
const draft = shallowDraft(saved); A shallow draft. After draft.recipient = 'Mina Rao'; saved reaches A, draft reaches C. A (id shipment-42, recipient Mina Patel, address → B); B (street 18 Cedar St, city Portland); C (id shipment-42, recipient Mina Rao, address → B). sameObject(saved, draft) is false. The spread made a new shipment, C, so the recipient edit stayed in the draft. Both shipments still point at one address, B.
A shallow draft.
The form opens a draft with a spread and changes the recipient. The saved recipient stays Mina Patel.
Reduced motion: choose a scene to see its completed state.
Read this scene
The form opens a draft with a spread and changes the recipient. The saved recipient stays Mina Patel.
A shallow draft. After draft.recipient = 'Mina Rao'; saved reaches A, draft reaches C. A (id shipment-42, recipient Mina Patel, address → B); B (street 18 Cedar St, city Portland); C (id shipment-42, recipient Mina Rao, address → B). sameObject(saved, draft) is false. The spread made a new shipment, C, so the recipient edit stayed in the draft. Both shipments still point at one address, B.
Watch restarts when you return. Step through keeps your selected step. Try it starts from a freshly loaded shipment each time you open it.
What a deliberate copy buys you
Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.
- Cancel that really cancels
- With the address copied, dropping the draft leaves the saved city as it was.
- Change detection that works
hasUnsavedChangescompares against a baseline nobody edited, so it sees the Salem edit.- Comparisons that say what they mean
sameObject,sameShipment, andsameDetailsanswer different questions, and the code says which one it asked.- Records found after a reload
- Looking a shipment up by ID finds it in a new response.
includes, which compares objects, doesn’t. - Copies sized to the job
- Only the objects the form edits get copied. A read-only preview can keep sharing.
The review words are shallow copy, deep copy, identity versus equality, and entity identity for the ID that says which record something is. Section 08 covers what they cost.
04 / Try a decision
An unsaved-changes check that never fires.
The form opens its draft with a spread and asks for confirmation on close when hasUnsavedChanges(saved, draft) is true. The code is in unsaved.ts, and the lesson’s tests pin what happens.
05 / Give it a real job
Keep a baseline, edit a copy, compare the two.
In the real dashboard, opening the address editor makes two deep copies of the saved shipment: a draft the form edits and a baseline nobody touches. Save sends the draft, Cancel throws it away, and the saved shipment only changes when a save succeeds.
Untouched while editing
It changes only after a successful save.
What changes are measured against
A copy made when the form opened.
What the form edits
A deep copy, so no write reaches the saved shipment.
The example leaves out saving, validation, and someone else editing the same shipment at the same time. Noticing that needs a version from the server, not a local baseline.
Build UIs?Every nested state update you write copies part of an object, and one day an unsaved-changes warning depends on getting that right.
Where it already is in your components
React’s documentation spreads both levels to change a nested field: a new artwork object, then a new person that points at it. The textbook
React panel does the same for the address, so the previous state and the new one don’t share
it.
Svelte’s $state makes nested objects reactive, so the textbook Svelte panel
binds straight to shipment.address.city. It takes its baseline first with $state.snapshot, which gives “a static snapshot of a deeply reactive $state proxy”.
When you have to own it
Now it’s the address editor. It makes its baseline and draft with structuredClone, so nothing the form does can reach the saved shipment, and isDirty compares the draft with the baseline.
While there are unsaved changes it listens for beforeunload. MDN recommends
adding that listener only when there’s something to lose, notes that the dialog shows only
“a generic browser-specified string”, and says browsers show it only after the person has
interacted with the page.
export type Address = { street: string; city: string };
export type Shipment = { id: string; recipient: string; address: Address };
// Two deep copies of the saved shipment: a draft the form edits, and a baseline nobody touches.
export function openEditor(saved: Shipment): { baseline: Shipment; draft: Shipment } {
return { baseline: structuredClone(saved), draft: structuredClone(saved) };
}
// Unsaved changes: any editable detail that differs from the baseline. The ID isn't editable.
export function isDirty(baseline: Shipment, draft: Shipment): boolean {
return (
draft.recipient !== baseline.recipient ||
draft.address.street !== baseline.address.street ||
draft.address.city !== baseline.address.city
);
}
A city field on a nested shipment: React copies both levels on each change, and Svelte binds to $state after taking a snapshot baseline.
import { useState } from 'react';
type Shipment = { id: string; recipient: string; address: { street: string; city: string } };
export function ShipmentAddress() {
const [shipment, setShipment] = useState<Shipment>({
id: 'shipment-42',
recipient: 'Mina Patel',
address: { street: '18 Cedar St', city: 'Portland' }
});
// The address is its own object. Copy it as well as the shipment, or the old state
// and the new one would share it.
function changeCity(city: string) {
setShipment({ ...shipment, address: { ...shipment.address, city } });
}
return (
<label>
City
<input value={shipment.address.city} onChange={(event) => changeCity(event.target.value)} />
</label>
);
}
06 / Recognize it elsewhere
Anywhere a copy or a comparison crosses an object.
You’ve met all of these. For each one, find what’s copied and what’s still shared.
| Where you’ve seen it | What’s copied | What’s still shared |
|---|---|---|
{ ...shipment } | The shipment’s own properties | Every object they point at |
structuredClone(shipment) | The whole shipment, address included | Nothing |
| Assigning a Go struct | Every field | Whatever a pointer, slice, or map field reaches |
| A nested React state update | The objects on the path to the change | Everything else, reused |
a === b on objects | Nothing | It asks whether they’re the same object |
Before trusting a copy, follow every property that points at another object. Before trusting a comparison, name the question it answers.
07 / Already in your toolbox
Your platform already documents where copies stop.
Three places to look. For each one, find what’s copied and what’s compared.
React · Updating a nested object
The two-level spread, and a short section, “Objects are not really nested”, that draws the pointers between the objects explicitly.
Read the section ↗MDN · Equality comparisons and sameness
Why two objects with the same structure are never equal under ===, ==, or Object.is, and how Object.is differs from === for NaN and signed zeroes.
MDN · structuredClone
The deep copy built into the platform, the types it supports, and the DataCloneError it throws for anything else.
A useful counterexample: a read-only previewWhen sharing everything is right
A shipment preview that never writes can reach the saved shipment directly. Copying it would only mean keeping two objects in step.
08 / The parts to watch
Copies and comparisons each answer less than they seem to.
These are the places it still goes wrong.
A spread copies one level
{ ...saved } gives you a new shipment and the same address. Copy each object
the code will write to.
Equality needs an unchanged baseline
Once the saved object has changed through a shared reference, no comparison can find the edit. Keep a baseline from before editing started.
Same ID, different details
sameShipment says which record; it doesn’t say the details match. A form that compares
IDs never sees an edit.
Exact comparison is a choice
Portland and portland differ here. If they shouldn’t, normalize on
purpose, in one place.
structuredClone copies everything
It copies parts nothing edits as well as the parts that are, and it throws on values it can’t serialize. For a small plain record that’s fine; for a large or unusual one, copy what you edit.
Go’s == on structs compares pointer fields
*a == *b is false for two shipments with identical details and separate addresses.
Compare the fields you mean.
09 / Make the call
What would you have to change tomorrow?
Give both drafts a plausible change and follow the work it creates.
| The change | Shallow draft | Deep draft with a baseline |
|---|---|---|
| The form only edits the recipient | Isolated, and simpler. | Also isolated, with an extra copy of the address. |
| The form edits the city | The saved city changes. | Only the draft changes. |
| Warn about unsaved changes | Misses edits to the address. | Compares against the baseline. |
| Cancel | Drops the draft, keeps any address write. | Nothing to undo. |
| The shipment gains a list of delivery notes | Shared too. | structuredClone copies it; a hand-written copy needs updating. |
Reach for a deep copy and a baseline whenever a form edits nested data and must be cancelable. An address inside a shipment is the moment.
Keep the spread when the record is flat, or when nothing writes to it.
The question I’d leave beside the code is: which objects can this code write to, and who else points at them?
10 / Take the idea with you
Explain the Cancel bug without saying “shallow copy.”
“The draft was a new shipment, but it pointed at the same address as the saved one, so changing the city changed both. Copying the address too fixed it.” In a review, the words are shallow copy, deep copy, identity, and equality.
Before moving on, jot down why the city edit reached the saved shipment, why the unsaved-changes check stayed quiet, and one object in your own code that a copy still shares.
Connections to follow nextRelated lessons
- Values and references is the layer underneath: names, references, and what a write reaches.
- Value objects never change after they’re made, so an address like that never needs copying.
- Idempotency and at-least-once delivery identifies a request by its key rather than its body, the same way this lesson finds a shipment by ID.