Mini-task: a quiz that starts each question with an empty answer. In main.jsx, write an Answer component that shows a question's text and an input whose value is its own answer state. Write a Quiz component that keeps the index of the current question in state, renders one Answer for it and a Next question button. Without a key, the typed answer would stay for the next question; give Answer a key, so that every question starts empty.
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 scoreboard where each player starts from zero
The two Counters sit at the same place in the JSX, but each has its own key, Mia or Leo. React uses the key as part of the position, so switching players removes one Counter with its state and creates the other from scratch. A run only mounts, so timers at the bottom of main.jsx click the buttons, one step every 50 ms, and print the heading after each change. In the browser the page stays live: click yourself.
main.jsx
import { useState } from 'react';
export default function Scoreboard() {
const [isPlayerA, setIsPlayerA] = useState(true);
return (
<div>
{isPlayerA ? (
<Counter key="Mia" person="Mia" />
) : (
<Counter key="Leo" person="Leo" />
)}
<button onClick={() => setIsPlayerA(!isPlayerA)}>Next player</button>
</div>
);
}
function Counter({ person }) {
const [score, setScore] = useState(0);
return (
<section>
<h1>{person}'s score: {score}</h1>
<button onClick={() => setScore(score + 1)}>Add one</button>
</section>
);
}
// A run only mounts. Act like the players, one step every 50 ms.
const click = name => [...document.querySelectorAll('button')].find(b => b.textContent === name).click();
const print = () => console.log(document.querySelector('h1').textContent);
const plan = [
() => click('Add one'),
() => click('Add one'),
() => click('Add one'),
print,
() => click('Next player'),
print,
() => click('Add one'),
print,
() => click('Next player'),
print,
];
plan.forEach((step, i) => setTimeout(step, 50 * (i + 1)));
Mia scored 3. Next player removed her Counter, and its state with it.
Leo's Counter started at 0, although it renders at the same place in the JSX.
Switching back created Mia's Counter again, from scratch: 0, not 3. Keeping her score would mean lifting it up into Scoreboard.
Without the keys, the ternary would keep one score for both players, as in the activity before.
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
Clear the draft when the recipient changes
Messenger shows a ContactList and a Chat for the chosen contact. Type a message to Mia, then choose Leo: the draft stays, so you could send Mia's message to Leo by accident. The Chat is the same component at the same position, so React keeps its state. Make React create a new Chat for every contact, so that switching always starts with an empty message. Change only how Messenger renders Chat.
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
A key tells React that two components at the same position are different.
Hint 2
Use something that identifies the contact, such as its id.
Hint 3
<Chat key={to.id} contact={to} />
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, fireEvent} from '@testing-library/react';
import Messenger from './main.jsx';
const message = (name) => screen.getByRole('textbox', {name: 'Message to ' + name});
const contact = (name) => screen.getByRole('button', {name});
test('A typed message stays while the contact stays', () => {
render(<Messenger />);
fireEvent.change(message('Mia'), {target: {value: 'Hi Mia'}});
assert.equal(message('Mia').value, 'Hi Mia', 'the message to Mia holds ' + JSON.stringify(message('Mia').value));
});
test('Choosing another contact clears the draft', () => {
render(<Messenger />);
fireEvent.change(message('Mia'), {target: {value: 'Hi Mia'}});
fireEvent.click(contact('Leo'));
assert.equal(message('Leo').value, '', 'after choosing Leo the message holds ' + JSON.stringify(message('Leo').value));
});
test('Every switch starts with an empty message', () => {
render(<Messenger />);
fireEvent.click(contact('Noor'));
fireEvent.change(message('Noor'), {target: {value: 'Lunch?'}});
fireEvent.click(contact('Mia'));
assert.equal(message('Mia').value, '', 'after switching from Noor to Mia the message holds ' + JSON.stringify(message('Mia').value));
assert.equal(screen.getByRole('button', {name: /^Send to/}).textContent, 'Send to mia@mail.com', 'the send button reads ' + JSON.stringify(screen.getByRole('button', {name: /^Send to/}).textContent));
});
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
Keep the scores with their players
Scoreboard shows Mia and Leo, and Swap changes their order. Give Mia two points and click Swap: Leo now has her 2 points and Mia has 0, because the state stays at its position while the people move. Fix it so that each score stays with its player, however often you swap. Keep the two branches and the Swap button.
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
Without keys React matches the two Counters by their order within the parent.
Hint 2
Keys are not only for lists: they work on any components with the same parent.
Hint 3
Give each Counter its player as the key, in both branches: <Counter key="Mia" person="Mia" />.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, fireEvent} from '@testing-library/react';
import Scoreboard from './main.jsx';
const scores = () => screen.getAllByRole('heading').map((h) => h.textContent).join(', ');
const add = (person) => fireEvent.click(screen.getByRole('button', {name: 'Add one for ' + person}));
const swap = () => fireEvent.click(screen.getByRole('button', {name: 'Swap'}));
test('Each counter adds to its own score', () => {
render(<Scoreboard />);
add('Mia');
add('Mia');
add('Leo');
assert.equal(scores(), 'Mia: 2, Leo: 1', 'after two points for Mia and one for Leo the scores read ' + JSON.stringify(scores()));
});
test('After a swap, the scores stay with their players', () => {
render(<Scoreboard />);
add('Mia');
add('Mia');
swap();
assert.equal(scores(), 'Leo: 0, Mia: 2', 'after two points for Mia and a swap the scores read ' + JSON.stringify(scores()));
});
test('Scores survive two swaps and more clicks', () => {
render(<Scoreboard />);
add('Mia');
add('Mia');
swap();
add('Leo');
assert.equal(scores(), 'Leo: 1, Mia: 2', 'after a swap and one point for Leo the scores read ' + JSON.stringify(scores()));
swap();
assert.equal(scores(), 'Mia: 2, Leo: 1', 'after swapping back the scores read ' + JSON.stringify(scores()));
});
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
Relying on state from the previous contact
import { useState } from 'react';
const chats = {
Mia: [
{ id: 0, text: 'See you at six?' },
{ id: 1, text: 'Bring the map.' },
{ id: 2, text: 'Thanks!' }
],
Leo: [{ id: 0, text: 'Running late.' }]
};
export default function Messenger() {
const [name, setName] = useState('Mia');
return (
<>
<button onClick={() => setName('Mia')}>Mia</button>
<button onClick={() => setName('Leo')}>Leo</button>
<Thread messages={chats[name]} />
</>
);
}
function Thread({ messages }) {
const [index, setIndex] = useState(0);
return (
<section>
<p>{messages[index].text}</p>
<button onClick={() => setIndex(index + 1)}>Older</button>
</section>
);
}
// A run only mounts. Act like a user: read Mia's third message, then open Leo.
setTimeout(() => [...document.querySelectorAll('button')].find(b => b.textContent === 'Older').click(), 50);
setTimeout(() => [...document.querySelectorAll('button')].find(b => b.textContent === 'Older').click(), 100);
setTimeout(() => [...document.querySelectorAll('button')].find(b => b.textContent === 'Leo').click(), 150);
What React prints
TypeError: Cannot read properties of undefined (reading 'text')
Why, and the fix
Thread stays at the same position, so its index 2 survives the switch, and Leo has only one message: messages[2] is undefined (Chrome and Node wording; Firefox and Safari word it differently). Give each conversation its own Thread: <Thread key={name} messages={chats[name]} />, so that index starts at 0 again.
Calling a component as a function
import { useState } from 'react';
export default function Scoreboard() {
const [showLeo, setShowLeo] = useState(false);
return (
<div>
{Counter({ person: 'Mia' })}
{showLeo && Counter({ person: 'Leo' })}
<button onClick={() => setShowLeo(true)}>Add Leo</button>
</div>
);
}
function Counter({ person }) {
const [score, setScore] = useState(0);
return <h1>{person}: {score}</h1>;
}
// A run only mounts. Act like a user: add Leo.
setTimeout(() => [...document.querySelectorAll('button')].find(b => b.textContent === 'Add Leo').click(), 50);
What React prints
Rendered more hooks than during the previous render.
Why, and the fix
Counter({ person: 'Mia' }) is a plain function call, not a component at a position, so its useState belongs to Scoreboard. Adding Leo adds a Hook to Scoreboard, and React stops. Render components with JSX, <Counter person="Mia" />, so that each gets its own position and its own state.
A reset state that starts from a missing prop
import { useState } from 'react';
const contacts = [
{ id: 0, name: 'Mia', draft: 'See you at six?' },
{ id: 1, name: 'Leo' }
];
export default function Messenger() {
const [to, setTo] = useState(contacts[0]);
return (
<>
{contacts.map(contact => (
<button key={contact.id} onClick={() => setTo(contact)}>
{contact.name}
</button>
))}
<Chat key={to.id} contact={to} />
</>
);
}
function Chat({ contact }) {
const [text, setText] = useState(contact.draft);
return (
<section>
<textarea value={text} onChange={e => setText(e.target.value)} />
<p>{text.length} / 280</p>
</section>
);
}
// A run only mounts. Act like a user: open Leo.
setTimeout(() => [...document.querySelectorAll('button')].find(b => b.textContent === 'Leo').click(), 50);
What React prints
TypeError: Cannot read properties of undefined (reading 'length')
Why, and the fix
The key works: choosing Leo creates a new Chat, and its useState runs again. But Leo has no draft, so text starts as undefined, and text.length throws (Chrome and Node wording; Firefox and Safari word it differently). Make the initial value valid for every contact: useState(contact.draft ?? '').
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
State belongs to a position in the tree
State is not stored in the JSX tag or in the component function. React holds it and ties it to where the component sits in the render tree. Two <Counter /> side by side are two positions, so each gets its own score, even if both come from one JSX variable. When a component stops being rendered, React destroys its state; when it comes back, its state starts from scratch. As long as the same component is rendered at the same position, React keeps the state, whatever props it gets. Two return statements in an if still give the same position if the tree they return has the same shape.
A different type at the same position resets the subtree
If a <p> replaces the <Counter> at a position, React removes the Counter and destroys its state. The same happens to everything below: if a <section> that holds a Counter turns into a <div>, the whole subtree is rebuilt and the Counter starts at 0. To keep state between renders, the structure of the tree must match from one render to the next. This is also why component definitions must not be nested: a component function declared inside another is a new function on every render, so React sees a different type at that position and resets its state each time.
Reset on purpose: different positions or a key
{isPlayerA ? <Counter person="Mia" /> : <Counter person="Leo" />} keeps one score for both players, because both are the same component at the same position. To reset it, render them at two positions, {isPlayerA && …}{!isPlayerA && …}, or give each an identity with a key: <Counter key="Mia" … />. React then uses the key as part of the position instead of the order within the parent. Keys only need to be unique among siblings. A key is the usual way to reset a form: <Chat key={to.id} contact={to} /> clears the draft when the recipient changes. Keeping a removed chat's draft means lifting it up, which is not covered here.