Lesson 0.9 · JavaScript for React
References and immutability
Value vs. reference, shallow copies, nested updates without mutation – and why React depends on it.
Loading lesson…
Lesson 0.9 · JavaScript for React
Value vs. reference, shallow copies, nested updates without mutation – and why React depends on it.
Loading lesson…
The previous lessons were general JavaScript. This one and the next are about the JavaScript that React requires. Skip them and you'll meet mysterious bugs in React: "I changed the data but the page didn't re-render" or "I changed one thing and something else broke".
Both bugs have the same cause: not understanding how JavaScript treats objects and arrays. Twins look the same, but they're two different people. And one person can have two nicknames, but it's still one person. This is exactly where JavaScript treats objects differently from numbers and text.
Primitive values (numbers, text, booleans) are copied when assigned. Each variable has its own:
let a = 5let b = a // b gets a COPY of the value 5b = 10a // 5 – a didn't change
Objects and arrays behave differently. The variable doesn't hold the object itself, but a tag with the address where the object lives. Assignment copies just the tag – both variables then lead to the same house:
const a = { count: 1 }const b = a // b is NOT a copy – it's a second tag with THE SAME addressb.count = 99a.count // 99 😱 "a" changed too – it's the same objectconst list = [1, 2]const sameList = listsameList.push(3)list // [1, 2, 3]
| Primitive values | Objects, arrays, functions | |
|---|---|---|
| Examples | 5, 'hello', true, null | { … }, [ … ], () => … |
| Assignment b = a | copies the value | copies the reference (the address) |
| a === b compares | the value | whether it’s THE SAME object |
| Can they be changed “inside”? | no (5 is simply 5) | yes (properties, items) |
'hello' === 'hello' // true – the same value[1, 2] === [1, 2] // false – two different arrays (two addresses) that just look the same{ a: 1 } === { a: 1 } // falseconst x = { a: 1 }const y = xx === y // true – the same address// Comparing by content is up to youx.a === y.aJSON.stringify(arr1) === JSON.stringify(arr2) // a quick (but imperfect) content check
Mutation means changing an existing object or array "inside" – overwriting a property, adding an item, sorting in place. The trouble starts when several places in the code share that object:
function sortByPrice(products) {return products.sort((a, b) => a.price - b.price) // ❌ sort changes the array it was given}const catalog = loadProducts() // in catalogue orderconst cheapFirst = sortByPrice(catalog)// 😱 catalog is now sorted by price too – the function reshuffled itfunction sortByPrice(products) {return products.toSorted((a, b) => a.price - b.price) // ✅ returns a new array}
| ❌ Mutates | ✅ Makes something new |
|---|---|
obj.name = 'x' | { ...obj, name: 'x' } |
delete obj.name | const { name, ...rest } = obj |
arr.push(x) | [...arr, x] |
arr.splice(i, 1) | arr.filter(…) / arr.toSpliced(i, 1) |
arr[i] = x | arr.map(…) / arr.with(i, x) |
arr.sort() | arr.toSorted() |
arr.reverse() | arr.toReversed() |
What you see: Two buttons, two ways to get an array with an extra plum. Below them, a printout of both arrays.
Try it:
fruits too, and next === fruits is true.next === fruits is false.The takeaway: Assignment doesn't copy. A new array (or object) is only created by spread, map, filter and similar methods – and that's the only kind of change React notices.
Loading the interactive part…
Spread makes a shallow copy: a new object on the first level, but the nested objects are still the same. Like photocopying a list of addresses – the paper is new, but the houses at the addresses are the same.
const user = { name: 'Anna', address: { city: 'London' } }const copy = { ...user }copy === user // false – a new objectcopy.address === user.address // true – the nested object is SHAREDcopy.name = 'Eve' // user.name stays 'Anna' ✅copy.address.city = 'Boston' // user.address.city is 'Boston' too 😱
const deep = structuredClone(user) // copies everything all the way downdeep.address === user.address // false// The older trick (loses Date, functions, undefined):const deep2 = JSON.parse(JSON.stringify(user))
You'll write these patterns in React again and again – on every state change. They're worth having at your fingertips.
const user = { name: 'Anna', age: 31, address: { city: 'London', zip: 'SW1A 1AA' } }{ ...user, age: 32 } // change a property{ ...user, email: 'anna@example.com' } // add a propertyconst { age, ...withoutAge } = user // remove a property{ ...user, [field]: value } // change a property named by a variable{ ...user, address: { ...user.address, city: 'Boston' } } // change a nested one – spread on every level{ ...user, age: user.age + 1 } // derive from the previous value
const todos = [{ id: 1, title: 'Go shopping', done: false },{ id: 2, title: 'Tidy up', done: true },][...todos, newTodo] // add to the end[newTodo, ...todos] // add to the starttodos.toSpliced(1, 0, newTodo) // insert at position 1todos.filter((t) => t.id !== id) // remove by idtodos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) // change one itemtodos.map((t) => (t.id === id ? { ...t, title: newTitle } : t)) // rename onetodos.map((t) => ({ ...t, done: true })) // change all of themtodos.filter((t) => !t.done) // remove all the finished onestodos.toSorted((a, b) => a.title.localeCompare(b.title)) // sort
const board = {title: 'Project',columns: [{ id: 'todo', cards: [{ id: 1, text: 'A' }] },{ id: 'done', cards: [] },],}// Add a card to the 'done' columnconst next = {...board, // 1. a new boardcolumns: board.columns.map((col) => // 2. a new array of columnscol.id === 'done'? { ...col, cards: [...col.cards, newCard] } // 3. a new column with a new array of cards: col, // the other columns unchanged),}
The procedure is always the same: follow the path to the change and make a copy on every level; leave everything off the path as it is. When the nesting gets too deep, it's a sign the state could be organised more flatly (lessons 5 and 15).
When you change state in React, React compares the old and the new value with === (more precisely Object.is). When it's the same object, it concludes nothing has changed and doesn't re-render the component.
// An example from lesson 2 – state with an array of todosconst [todos, setTodos] = useState([])// ❌ Mutation: the array is still THE SAME, React notices nothing, the page doesn't changetodos.push(newTodo)setTodos(todos)// ✅ A new array: React sees a different address → re-renderssetTodos([...todos, newTodo])
memo, useMemo and effect dependencies only work because unchanged data keep the same address and changed data get a new one.Add an item to an array and rename a user – each time so that a new array or a new object is created.
CHALLENGE: New instead of changed
The inputs are frozen – neither push nor assigning to a property will work.
1. addFruit(['apple'], 'pear') should return a NEW array ['apple', 'pear']: [...list, fruit]
2. rename(user, 'Eve') should return a NEW object with the name 'Eve': { ...user, name }Loading the interactive part…
The first challenge practises the basic immutable updates on simple data. The second goes deeper: state with projects and tasks, where the tests use === to check that you copy only what's necessary – exactly like React.
Six small functions that return new data instead of changing the old. You'll write exactly such functions for changing state in the next lessons.
CHALLENGE: No mutations (arrow functions, destructuring, spread, ?. and ??)
Below this comment there are six functions. Write their bodies. The tests below them run on their own
and in the course you'll see ✅ or ❌ next to each one.
The rule: NEVER change the inputs. Always return a new array/object. The tests freeze the inputs
(deepFreeze), so an attempt at a mutation ends with an error.
1. describe(user) – return the text 'Anna Smith (London)'. Use destructuring and a template string `${…}`.
2. addTask(tasks, task) – return a new array with `task` added to the end (spread [...]).
3. removeTask(tasks, id) – return a new array without the task with the given id (filter).
4. toggleTask(tasks, id) – return a new array where the task with the given id has `done` flipped;
the other tasks stay the same objects (map + object spread { ...t, done: … }).
5. moveTo(user, city) – return a new user object with a different city (object spread).
6. cityOf(user) – return the user's city, or 'unknown' when the user is missing (?. and ??).Loading the interactive part…
The app state with a user, projects and tasks. Write six functions that change one thing in it – and leave everything else as the same objects.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
=== on objects compares the address, not the content. Two objects that look the same aren't "the same".{ ...obj, key: value }.Checks, deepFreeze, equal, Check)Checks, deepFreeze, equal, Check – from the file src/course/checks.tsx. The course helper for function-style challenges: runs the tests and shows ✅/❌. You don't need to know how it works inside – it just tells you which steps are done./** One test: a description and a function returning true (passed) or false. It may be async. */export type Check = [name: string, test: () => boolean | Promise<boolean>]/*** Runs the tests of a challenge and shows ✅ or ❌ for each.* An error thrown in a test (e.g. when trying to change a frozen object) is shown under the test.*/export function Checks({ tests }: { tests: Check[] }) {const t = useT()const [results, setResults] = useState<Result[]>(() => tests.map(() => ({ status: 'pending' })))useEffect(() => {let ignore = falsetests.forEach(([, test], i) => {Promise.resolve().then(test).then((ok): Result => (ok ? { status: 'ok' } : { status: 'fail' }),(e): Result => ({ status: 'fail', error: e instanceof Error ? e.message : String(e) }),).then((result) => {if (ignore) returnsetResults((prev) => prev.map((r, idx) => (idx === i ? result : r)))})})return () => {ignore = true}}, [tests])const passed = results.filter((r) => r.status === 'ok').lengthreturn (<div className="stack"><strong className={passed === tests.length ? 'ok' : undefined}>{t.checksPassed(passed, tests.length)}</strong><ul className="list">{tests.map(([name], i) => {const r = results[i] ?? { status: 'pending' }return (<li key={name}>{r.status === 'pending' ? '⏳' : r.status === 'ok' ? '✅' : '❌'} {name}{r.status === 'fail' && r.error && (<div className="bad muted">{t.checksError} {r.error}</div>)}</li>)})}</ul></div>)}/** Freezes an object and all nested objects and arrays – any attempt to change them throws. */export function deepFreeze<T>(value: T): T {if (value && typeof value === 'object') {Object.values(value).forEach(deepFreeze)Object.freeze(value)}return value}/** Compares two values by content (arrays and objects item by item), not by reference. */export function equal(a: unknown, b: unknown): boolean {return JSON.stringify(a) === JSON.stringify(b)}
Checks, deepFreeze, Check)Checks, deepFreeze, Check – from the file src/course/checks.tsx. The course helper for function-style challenges: runs the tests and shows ✅/❌. You don't need to know how it works inside – it just tells you which steps are done./** One test: a description and a function returning true (passed) or false. It may be async. */export type Check = [name: string, test: () => boolean | Promise<boolean>]/*** Runs the tests of a challenge and shows ✅ or ❌ for each.* An error thrown in a test (e.g. when trying to change a frozen object) is shown under the test.*/export function Checks({ tests }: { tests: Check[] }) {const t = useT()const [results, setResults] = useState<Result[]>(() => tests.map(() => ({ status: 'pending' })))useEffect(() => {let ignore = falsetests.forEach(([, test], i) => {Promise.resolve().then(test).then((ok): Result => (ok ? { status: 'ok' } : { status: 'fail' }),(e): Result => ({ status: 'fail', error: e instanceof Error ? e.message : String(e) }),).then((result) => {if (ignore) returnsetResults((prev) => prev.map((r, idx) => (idx === i ? result : r)))})})return () => {ignore = true}}, [tests])const passed = results.filter((r) => r.status === 'ok').lengthreturn (<div className="stack"><strong className={passed === tests.length ? 'ok' : undefined}>{t.checksPassed(passed, tests.length)}</strong><ul className="list">{tests.map(([name], i) => {const r = results[i] ?? { status: 'pending' }return (<li key={name}>{r.status === 'pending' ? '⏳' : r.status === 'ok' ? '✅' : '❌'} {name}{r.status === 'fail' && r.error && (<div className="bad muted">{t.checksError} {r.error}</div>)}</li>)})}</ul></div>)}/** Freezes an object and all nested objects and arrays – any attempt to change them throws. */export function deepFreeze<T>(value: T): T {if (value && typeof value === 'object') {Object.values(value).forEach(deepFreeze)Object.freeze(value)}return value}