Replace an object in state with a copy made by spread, also a nested one, and handle many fields with one handler
Add, remove, replace, insert and reorder array items in state without mutating the array
Update one object inside an array with map, so that no other list or render sees the change
Warm-up · Activity 1 of 7
Warm-up from lesson B3.4: state as a snapshot. The handler sets tags twice, each time with a new array built from tags. You click once. What does the button show?
One handler serves both inputs, <input name="first"> and <input name="last">. Fill in the key, so that the handler changes the field named by the input that changed and keeps the other one.
function handleChange(e) {
setPerson({
...person,
____: e.target.value
});
}
: e.target.value
Practice · Activity 4 of 7
Match each change to an array in state with the way that makes a new array.
Practice · Activity 5 of 7
The handler copies person, but writes a new artwork with only a city. You click Move. What does the <p> show (spaces at the ends left out)?
Brain teaser. Two lists start from the same array. The handler copies myList, changes its first item and sets myList. You click once. What do the two <p> show?
Mini-task: a guest list. In main.jsx, write a GuestList component with an input and an Add button that adds the typed name at the end of a guests array (each guest an object with an id and a name) and clears the input. Every guest gets a Remove button. A Sort A–Z button sorts the guests by name. Never mutate the state array. Run it: add Zoe, Ben and Mia, sort, then remove Ben.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
A playlist: like, insert and reverse without mutation
Playlist keeps an array of song objects. Like makes a new array with map, and a new object for the liked song only. Insert puts a song at index 1 with two slices and spread. Reverse copies the array before reversing it. A run only mounts, so timers at the bottom of main.jsx click Like on Sunrise, then Insert, then Reverse, and log the first item of the initial array. In the browser the page stays live: click the buttons yourself.
main.jsx
import { useState } from 'react';
let nextId = 3;
const initialSongs = [
{ id: 0, title: 'Sunrise', likes: 0 },
{ id: 1, title: 'Night drive', likes: 2 },
{ id: 2, title: 'Rainy day', likes: 5 },
];
export default function Playlist() {
const [songs, setSongs] = useState(initialSongs);
function handleLike(songId) {
setSongs(songs.map(song => {
if (song.id === songId) {
// A new object with one field changed
return { ...song, likes: song.likes + 1 };
} else {
// No change
return song;
}
}));
}
function handleInsert() {
const insertAt = 1;
setSongs([
...songs.slice(0, insertAt),
{ id: nextId++, title: 'Interlude', likes: 0 },
...songs.slice(insertAt)
]);
}
function handleReverse() {
const nextSongs = [...songs];
nextSongs.reverse();
setSongs(nextSongs);
}
return (
<>
<button onClick={handleInsert}>Insert</button>
<button onClick={handleReverse}>Reverse</button>
<ul>
{songs.map(song => (
<li key={song.id}>
{song.title} ({song.likes}){' '}
<button onClick={() => handleLike(song.id)}>Like</button>
</li>
))}
</ul>
</>
);
}
// A run only mounts. Act like a user: like Sunrise, insert, then reverse.
setTimeout(() => {
document.querySelector('li button').click();
setTimeout(() => {
document.querySelector('button').click();
setTimeout(() => {
document.querySelectorAll('button')[1].click();
setTimeout(() => {
console.log('initialSongs[0].likes is still', initialSongs[0].likes);
}, 50);
}, 50);
}, 50);
}, 50);
Run it with
npm run dev
Output
initialSongs[0].likes is still 0
<!-- #root -->
<div id="root"><button>Insert</button><button>Reverse</button><ul><li>Rainy day (5) <button>Like</button></li><li>Night drive (2) <button>Like</button></li><li>Interlude (0) <button>Like</button></li><li>Sunrise (1) <button>Like</button></li></ul></div>
Sunrise shows 1 like, but initialSongs[0].likes is still 0: map made a new object for it instead of changing the old one.
Interlude went in at index 1, between Sunrise and Night drive, before the list was reversed.
Reverse changed only the copy, nextSongs; songs from the earlier render keeps its order.
The keys are the songs' ids, so React keeps each <li> with its song when the order changes.
Change it and run it
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.
Your page
Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.
Run the program to see its page here.
Exercises
Exercise 1 of 2
Fix a form with a nested address
ProfileForm edits a person with a nested address. Typing into any field changes nothing on the screen, because both handlers mutate the state object. Rewrite them. handleChange updates name or email with one copy and a computed key, [e.target.name]. handleAddressChange updates street or city: copy the address and the person. Keep initialPerson exported and unchanged.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.
Your page
Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.
Run the program to see its page here.
Hints
Hint 1
Each input has a name that matches its key: name, email, street, city.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ProfileForm, {initialPerson} from './main.jsx';
const field = (name) => screen.getByRole('textbox', {name});
const summary = () => screen.getByText(/lives at/).textContent;
test('Typing a new name shows it', async () => {
const user = userEvent.setup();
render(<ProfileForm />);
await user.clear(field('Name:'));
await user.type(field('Name:'), 'Grace Hopper');
assert.equal(field('Name:').value, 'Grace Hopper', 'after typing, the Name field holds ' + JSON.stringify(field('Name:').value));
assert.equal(summary(), 'Grace Hopper (ada@example.com) lives at 1 Main Street, London.', 'after typing a name, the summary reads ' + JSON.stringify(summary()));
});
test('Typing a new city keeps the name and the street', async () => {
const user = userEvent.setup();
render(<ProfileForm />);
await user.clear(field('City:'));
await user.type(field('City:'), 'Paris');
assert.equal(summary(), 'Ada Lovelace (ada@example.com) lives at 1 Main Street, Paris.', 'after typing a city, the summary reads ' + JSON.stringify(summary()));
});
test('The initial object is not changed', async () => {
const user = userEvent.setup();
render(<ProfileForm />);
await user.type(field('Email:'), '.uk');
await user.type(field('Street:'), ' West');
assert.equal(initialPerson.email, 'ada@example.com', 'after typing, initialPerson.email is ' + JSON.stringify(initialPerson.email));
assert.equal(initialPerson.address.street, '1 Main Street', 'after typing, initialPerson.address.street is ' + JSON.stringify(initialPerson.address.street));
});
In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.
Run the program:
npm run dev
There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.
Exercise 2 of 2
Add, toggle and delete todos
TodoList shows three todos. Add pushes into the state array, so nothing appears; a checkbox changes its todo object, so the tick does not stay; Delete does nothing. Fix all three without mutating: Add puts a new todo with the typed title at the end and clears the field, a checkbox flips done for its own todo only, and Delete removes its own todo. Keep initialTodos exported and unchanged.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.
Your page
Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, within} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList, {initialTodos} from './main.jsx';
const titles = () => screen.queryAllByRole('listitem').map((li) => li.textContent.replace('Delete', ''));
const box = (name) => screen.getByRole('checkbox', {name});
const deleteButton = (title) => within(screen.getByText(title).closest('li')).getByRole('button', {name: 'Delete'});
test('Add puts the new todo at the end and clears the field', async () => {
const user = userEvent.setup();
render(<TodoList />);
await user.type(screen.getByRole('textbox', {name: 'New todo:'}), 'Walk the dog');
await user.click(screen.getByRole('button', {name: 'Add'}));
assert.deepEqual(titles(), ['Buy milk', 'Water plants', 'Call Grandma', 'Walk the dog'], 'after Add the list is ' + JSON.stringify(titles()));
assert.equal(screen.getByRole('textbox', {name: 'New todo:'}).value, '', 'after Add the field holds ' + JSON.stringify(screen.getByRole('textbox', {name: 'New todo:'}).value));
});
test('A checkbox toggles only its own todo', async () => {
const user = userEvent.setup();
render(<TodoList />);
await user.click(box('Water plants'));
assert.equal(box('Water plants').checked, true, 'after one click Water plants is checked: ' + box('Water plants').checked);
assert.equal(box('Call Grandma').checked, false, 'Call Grandma is checked: ' + box('Call Grandma').checked);
await user.click(box('Buy milk'));
assert.equal(box('Buy milk').checked, false, 'after one click Buy milk is checked: ' + box('Buy milk').checked);
});
test('Delete removes only its own todo', async () => {
const user = userEvent.setup();
render(<TodoList />);
await user.click(deleteButton('Water plants'));
assert.deepEqual(titles(), ['Buy milk', 'Call Grandma'], 'after deleting Water plants the list is ' + JSON.stringify(titles()));
});
test('The initial array and its objects are not changed', async () => {
const user = userEvent.setup();
render(<TodoList />);
await user.click(box('Call Grandma'));
await user.type(screen.getByRole('textbox', {name: 'New todo:'}), 'Read');
await user.click(screen.getByRole('button', {name: 'Add'}));
assert.equal(initialTodos.length, 3, 'after Add initialTodos has ' + initialTodos.length + ' items');
assert.equal(initialTodos[2].done, false, 'after the click initialTodos[2].done is ' + initialTodos[2].done);
});
In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.
Run the program:
npm run dev
There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.
Common mistakes
Setting an object without copying the other fields
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
firstName: 'Ada',
lastName: 'Lovelace'
});
function handleChange(e) {
setPerson({ [e.target.name]: e.target.value });
}
return (
<>
<input name="firstName" value={person.firstName} onChange={handleChange} />
<input name="lastName" value={person.lastName} onChange={handleChange} />
</>
);
}
// A run only mounts. Act like a user: type into the first field.
setTimeout(() => {
const input = document.querySelector('input');
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
setValue.call(input, 'Grace');
input.dispatchEvent(new window.Event('input', { bubbles: true }));
}, 50);
What React prints
A component is changing a controlled input to be uncontrolled.
Why, and the fix
The new object has only firstName, so person.lastName becomes undefined, and the second input switches from controlled to uncontrolled; React warns. A set call replaces the whole object; it does not merge. Copy the other fields first: setPerson({ ...person, [e.target.name]: e.target.value }).
Copying an object with square brackets
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({ name: 'Ada', city: 'London' });
function handleClick() {
setPerson([...person, { city: 'Paris' }]);
}
return <button onClick={handleClick}>{person.name} in {person.city}</button>;
}
// Act like a user: click once.
setTimeout(() => document.querySelector('button').click(), 50);
What React prints
TypeError: person is not iterable
Why, and the fix
[...person] is the array spread syntax, and a plain object cannot be spread into an array, so the click throws (Chrome and Node wording; Firefox and Safari word it differently). Copy an object with curly braces and override the field: setPerson({ ...person, city: 'Paris' }).
TypeError: Cannot read properties of undefined (reading 'done')
Why, and the fix
map puts whatever the callback returns into the new array. For every todo except the clicked one, this callback returns nothing, so the new array holds undefined, and the render throws at todo.done (Chrome and Node wording; Firefox and Safari word it differently). Return the old object for the others: add return todo; after the if.
React in the browser: React 19.3.0 (development build) and React Testing Library, MIT, served from this site; JSX is compiled by TypeScript 6.0.3, Apache-2.0. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.
Key ideas
Treat state as read-only and replace objects
An object in state can be changed, position.x = 5, but React has no idea it changed, so nothing re-renders, and the old render's snapshot changes under you. Treat every object in state as read-only: create a new object and pass it to the set function. To keep the other fields, copy them with the object spread syntax: setPerson({ ...person, firstName: e.target.value }). One handler can serve many inputs when each input has a name: [e.target.name]: e.target.value uses that name as the key. Changing an object you have just created is fine, because nothing else references it yet.
Nested objects: copy every level
Spread is shallow: it only copies one level deep. In { ...person }, person.artwork is still the very same object, so changing the copy's artwork changes the state too. To change person.artwork.city, make a new artwork and a new person that points at it: setPerson({ ...person, artwork: { ...person.artwork, city: 'Berlin' } }). Copy all the way up from the place you change. A nested object is really a separate object that another one points to, and other objects may point to it as well. For deeply nested state, the Immer library lets you write mutating code on a draft and makes the copies; it runs only on your own computer, not in this course's runner.
Arrays: make a new array
Arrays are objects too, so treat them as read-only. Avoid push, unshift, pop, shift, splice, arr[i] = … and calling sort or reverse on the state; prefer methods that return a new array. Add with [...arr, item] or [item, ...arr]. Remove with filter. Transform or replace items with map. Insert with [...arr.slice(0, i), item, ...arr.slice(i)]. To sort or reverse, copy first, [...arr], then change the copy. The copy is shallow too: to change an object inside the array, map to a new object for that item, { ...item, seen: true }, and return the others unchanged.