01 / The idea
Letting a helper edit the settings is a fair start.
You’re building a report builder. The open report’s settings are held by the settings form
and by a live preview, and a helper, applyPageSize, edits them. Both screens
reach the same object, so the preview updates without anyone copying anything.
Read the first helperTypeScript · the version this lesson starts from
// The first version: a helper edits the settings it's given. Every screen holding them sees it.
export function applyPageSize(settings: ReportSettings, pageSize: number): void {
settings.pageSize = pageSize;
} Go’s version takes a pointer to the settings. Both languages meet again at writeThrough in section 02.
Then a teammate writes a helper that starts from a shared preset. It assigns the preset to its parameter and sets the page size to 99. The caller’s settings don’t change at all. The preset does, and every report that starts from it now shows 99 rows. Nobody meant to write to the preset.
A name holds a value. For an object, that value is a reference: a way to reach the object, not the object itself. Assigning to a name changes what it reaches. Writing a property changes the object, for every name that reaches it. JavaScript and Go both pass arguments by value, and when the value is a reference, the caller and the function reach the same object.
React’s documentation turns that into an everyday rule: “treat any JavaScript object that you put into state as read-only.” Section 05 shows why, with a list of reports that doesn’t update.
02 / See the shape
Three lines that look alike, and one way to stop sharing.
The basic form is three functions: assign a number parameter, write through an object parameter, reassign an object parameter. In the wild adds settings nobody can edit and a function that returns new settings instead of changing old ones. At the call site runs all of them.
Both languages run the same functions and produce the same six numbers.
Three lines that look alike: assigning a number parameter, writing through an object parameter, and reassigning an object parameter. Only the middle one reaches the caller.
// Three lines that look alike and write to different places.
export function changeNumber(pageSize: number, next: number): number {
pageSize = next; // Changes only this parameter.
return pageSize;
}
export function writeThrough(settings: ReportSettings, next: number): void {
settings.pageSize = next; // Changes the object the caller reaches too.
}
export function rebind(settings: ReportSettings, other: ReportSettings): ReportSettings {
settings = other; // Changes only which object this parameter reaches.
return settings;
} // Three lines that look alike and write to different places.
func ChangeNumber(pageSize, next int) int {
pageSize = next // Changes only this parameter.
return pageSize
}
func WriteThrough(settings *ReportSettings, next int) {
settings.PageSize = next // Changes the struct the caller's pointer reaches too.
}
func Rebind(settings, other *ReportSettings) *ReportSettings {
settings = other // Changes only which struct this parameter points at.
return settings
} Reading the TypeScriptconst, Readonly, and freeze stop different things
const stops a name being reassigned; the object it reaches can still
change. Readonly<ReportSettings> stops writes through that type, but not
through another name typed without it.
Object.freeze stops writes to the object at runtime, and only to its own
properties; an object nested inside stays writable. standard uses both the type
and the freeze.
Reading the GoPointers share, structs copy
WriteThrough takes a *ReportSettings, so both sides reach one
struct. WithPageSize takes a ReportSettings by value: the struct
is copied into the parameter, so editing it can’t affect the caller.
Go has no freeze. Standard stays 25 here because every caller gets a copy,
but it’s an exported variable, so any code could still write Standard.PageSize = 99. To rule that out, keep it unexported and hand out
copies from a function. A struct that holds a pointer, a slice, or a map still shares
whatever that field reaches.
03 / Follow the names
Watch what each name reaches.
Five steps, each running the lesson’s functions on real objects. Names on the left point at objects on the right. A, B, and C label objects in the order they appear, not memory addresses. Before each step, guess which object the line changes.
In Try it, write through a name or point it at another object yourself.
What each name reaches, line by line.
A number is copied
Line 1 of 2
const size = saved.pageSize; A number is copied. After const next = changeNumber(size, 40); saved reaches A, size is 25, next is 40. A has pageSize 25. changeNumber assigned its own parameter. size is still 25, and so is the page size in the settings.
A number is copied.
Passing size hands the function its own 25. Assigning the parameter leaves the caller’s size alone.
Reduced motion: choose a scene to see its completed state.
Read this scene
Passing size hands the function its own 25. Assigning the parameter leaves the caller’s size alone.
A number is copied. After const next = changeNumber(size, 40); saved reaches A, size is 25, next is 40. A has pageSize 25. changeNumber assigned its own parameter. size is still 25, and so is the page size in the settings.
Watch restarts when you return. Step through keeps your selected step. Try it starts from the same three names each time you open it.
What knowing the difference 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.
- Edits you can predict
- A write through a parameter changes the caller’s object; an assignment never does. The line alone tells you which.
- Sharing on purpose
- The form and the preview agree because they reach one object, not because something keeps two copies in step.
- Defaults that stay put
- A frozen
standardcan be handed to every report, and no report can change it for the others. - Cheap change detection
- A new object means something changed. React’s
memoand Svelte’s raw state rely on exactly that. - Changes the caller chooses
withPageSizereturns the change, and the caller decides whether to keep it.
The review words are aliasing for two names reaching one object, pass by value, which still passes the reference, mutation, and immutability for values nobody changes after they’re made. Section 08 covers what they cost.
04 / Try a decision
A default that every report shares.
Someone writes settingsFor so each report starts from the defaults: const settings = defaults;, then the page size. The code is in presets.ts, and the lesson’s tests pin what it returns.
05 / Give it a real job
Share the open report. Hand out copies of defaults.
In the real report builder, the open report’s settings are one object shared by the form and the preview. Presets and defaults are frozen and never edited. A report that starts from a preset gets its own object.
Shared on purpose
The form writes and the preview reads the same object.
Frozen
Every report starts from a copy.
Compares identity
A changed report is a new object, so only its row updates.
The example leaves out nested settings, like a delivery schedule inside a report. A spread copies only the top level, so nested objects stay shared; Copying, identity, and equality follows that further.
Build UIs?Every state update you write chooses between changing an object and replacing it, and one day a memoized list shows you the difference.
Where it already is in your components
React’s documentation is direct. Change a state object in place and “React has no idea that object has changed. So React does not do anything in response.” The textbook React panel replaces the settings object instead.
Svelte takes the other road. $state turns a plain object into “a deeply
reactive state proxy”, so writing settings.pageSize is noticed, and the textbook
Svelte panel binds straight to it.
When you have to own it
Now it’s a long list of reports whose titles you can edit. In React, each row is wrapped
in memo, and “React will compare each prop with Object.is.” Rename a report
by writing report.title and the row gets the same object back, so it keeps showing
the old title.
In Svelte, the list uses $state.raw, which “cannot be mutated; it can only be
reassigned.” Writing report.title changes the object and nothing updates. renameReport fixes both: it replaces only the renamed report, so that row updates
and the rest keep their identity.
export type Report = { id: string; title: string; pageSize: number };
// Changing one report makes a new object for it and a new array. Every other report keeps its
// identity, so anything comparing references knows exactly which one changed.
export function renameReport(reports: readonly Report[], id: string, title: string): Report[] {
return reports.map((report) => (report.id === id ? { ...report, title } : report));
}
A rows-per-page setting: React replaces the settings object, and Svelte binds to a deeply reactive $state object.
import { useState } from 'react';
type Settings = { pageSize: number; format: 'csv' | 'pdf' };
export function ReportSettingsPanel() {
const [settings, setSettings] = useState<Settings>({ pageSize: 25, format: 'csv' });
// settings.pageSize = size would change the object React already has, and React wouldn't know
// anything changed. Give it a new object instead.
function changePageSize(size: number) {
setSettings({ ...settings, pageSize: size });
}
return (
<label>
Rows per page
<select
value={settings.pageSize}
onChange={(event) => changePageSize(Number(event.target.value))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</label>
);
}
06 / Recognize it elsewhere
Anywhere a value crosses a boundary.
You’ve met all of these. For each one, find what’s copied and what’s shared.
| Where you’ve seen it | What’s copied | What stays shared |
|---|---|---|
| Passing an object to a function | The reference | The object |
{ ...settings } | The top-level properties | Any object nested inside |
| A Go struct passed by value | Every field | Whatever a pointer, slice, or map field reaches |
React memo props | Nothing | Each prop, compared with Object.is |
Svelte $state.raw | Nothing | The object; only reassigning is noticed |
When two parts of a program see each other’s changes and nobody expected them to, look for two names reaching one object.
07 / Already in your toolbox
Your languages and frameworks already document this.
Three places to look. For each one, find what a write reaches.
React · Updating objects in state
Why state objects should be treated as read-only, what happens when they aren’t, and when mutating is fine: “Mutating an object you’ve just created is okay because no other code references it yet.”
Read the guide ↗Svelte · $state
Deep proxies, $state.raw for values you only reassign, and $state.snapshot for handing a plain copy to code that doesn’t expect a proxy.
MDN · Functions
The JavaScript guide’s section on function parameters: primitives passed by value, and objects whose properties a function can change for the caller.
Read the guide ↗A useful counterexample: the live previewWhen sharing is the point
The form and the preview reaching one object is the right design: an edit shows up immediately, with nothing to copy or keep in step. Copying there would mean syncing two objects by hand.
08 / The parts to watch
Every write lands somewhere. Check where.
These are the places it still surprises people.
Reassigning a parameter never reaches the caller
If a function needs the caller to hold a different object, return it and let the caller assign it.
Assigning an object isn’t copying it
const settings = defaults is section 04’s bug: another name, the same object.
const doesn’t freeze
const settings stops settings = other. It doesn’t stop settings.pageSize = 99.
Freeze and spread both stop at one level
Object.freeze protects the object’s own properties, and { ...settings } copies them. An object nested inside is still shared and still
writable in both cases.
Go structs copy; their pointers don’t
Passing a struct by value copies its fields. A pointer, slice, or map field still reaches the same data as the original.
09 / Make the call
What would you have to change tomorrow?
Give both approaches a plausible change and follow the work it creates.
| The change | Edit the object in place | Return a new object |
|---|---|---|
| The preview must follow the form as it changes | Free: both reach one object. | The form has to hand each new object to the preview. |
| Reports start from a shared preset | One report’s edit changes the preset for all. | Each report gets its own object. |
| A memoized row must update on rename | Same object, so the row keeps the old title. | A new object, so the row updates. |
| Undo the last change | The old value is gone. | The previous object is still there to go back to. |
Reach for a new object whenever someone else may hold the old one and shouldn’t see the change: defaults, presets, and state a renderer compares.
Keep editing in place when every holder is meant to see the change, like a form and its live preview, or an object only one function has touched.
The question I’d leave beside the code is: who else reaches this object, and should they see this write?
10 / Take the idea with you
Explain the preset bug without saying “reference.”
“settings and defaults were two names for the same object, so
changing one changed the other. Giving each report its own copy fixed it.” In a review, the
words are aliasing, mutation, pass by value, and immutability.
Before moving on, jot down why reassigning the parameter didn’t change the caller, why the next write changed the preset, and one object in your own code that two parts of the program both hold.
Connections to follow nextRelated lessons
- Copying, identity, and equality goes one level deeper: what a copy keeps shared, and when two objects count as equal.
- Value objects make settings like these unchangeable by construction, so sharing them is always safe.
- Closures and captured state keep a name alive after its function returns, along with whatever that name reaches.
- Allocation and memory locality asks what making all those new objects costs.