After this lesson you replace forgetful local variables with state, give a component several state variables, and know why each copy of a component remembers on its own.
Brain teaser. Each Likes button shows its own state and a module variable, shared. You click the first button twice, then the second once. What do the two buttons show?
Mini-task: flashcards. In main.jsx, write a Flashcards component over an array of cards, each with a question and an answer. It keeps two state variables: index, the current card, and showAnswer. It shows the question in an <h2>, then either the answer or a Show answer button, then a Next button. Next goes to the next card, back to the first after the last, and hides the answer again. Run it, show an answer, then click Next.
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 colour gallery with two state variables
Gallery shows one colour from data.js at a time. index, the current colour, and showMore, whether the details are shown, are two state variables. Next moves index on, back to 0 after the last colour; Show details flips showMore. A run only shows the first render, so the output is Tomato with its details hidden. In the browser the page stays live: click Next and Show details to see the state change.
export const colours = [
{ name: 'Tomato', hex: '#ff6347', details: 'A warm red with a touch of orange.' },
{ name: 'Teal', hex: '#008080', details: 'A calm, deep blue-green.' },
{ name: 'Gold', hex: '#ffd700', details: 'A bright yellow with a warm glow.' }
];
<!-- #root -->
<div id="root"><section><button>Next</button><h2>Tomato</h2><p>(1 of 3)</p><div class="swatch" style="background: rgb(255, 99, 71);"></div><button>Show details</button></section></div>
useState(0) and useState(false) give the initial values on the first render: the first colour, details hidden.
jsdom writes the colour #ff6347 as rgb(255, 99, 71); a browser does the same in the HTML it shows.
false renders nothing, so the details paragraph is missing until showMore is true.
After a click on Next, React renders Gallery again and useState(0) returns 1: React remembers the value, not the 0.
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
Give the gallery a memory
Page renders two galleries of colours. Run it and click Next: nothing changes, because Gallery keeps index in a local variable. Import useState from react and keep index in state instead, starting at 0, and change it with the setter in handleNextClick. Next on the last colour must go back to the first. The two galleries must keep separate indexes. Keep the export in front of Gallery.
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
Add import { useState } from 'react'; at the top of main.jsx.
Hint 2
Replace let index = 0; with const [index, setIndex] = useState(0);.
Hint 3
In handleNextClick, write setIndex(index + 1) and setIndex(0) instead of assigning to index.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.jsx
import { colours } from './data.js';
export function Gallery() {
// TODO: a local variable is not enough. Keep index in state.
let index = 0;
function handleNextClick() {
if (index < colours.length - 1) {
index = index + 1;
} else {
index = 0;
}
}
const colour = colours[index];
return (
<section>
<h2>{colour.name}</h2>
<p>
({index + 1} of {colours.length})
</p>
<button onClick={handleNextClick}>Next</button>
</section>
);
}
export default function Page() {
return (
<>
<Gallery />
<Gallery />
</>
);
}
main.test.jsx
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 Page from './main.jsx';
const names = () => screen.getAllByRole('heading', {level: 2}).map((h) => h.textContent);
const nextButtons = () => screen.getAllByRole('button', {name: 'Next'});
test('Next shows the second colour', async () => {
const user = userEvent.setup();
render(<Page />);
await user.click(nextButtons()[0]);
assert.deepEqual(names(), ['Teal', 'Tomato'], 'after one click on the first Next the galleries show ' + JSON.stringify(names()));
});
test('Next on the last colour goes back to the first', async () => {
const user = userEvent.setup();
render(<Page />);
for (let i = 0; i < 3; i++) await user.click(nextButtons()[0]);
assert.equal(names()[0], 'Tomato', 'after three clicks the first gallery shows ' + JSON.stringify(names()[0]));
const count = screen.getAllByText(/of 3/)[0].textContent;
assert.equal(count, '(1 of 3)', 'after three clicks the first counter reads ' + JSON.stringify(count));
});
test('Each gallery keeps its own index', async () => {
const user = userEvent.setup();
render(<Page />);
await user.click(nextButtons()[1]);
await user.click(nextButtons()[0]);
await user.click(nextButtons()[0]);
assert.deepEqual(names(), ['Gold', 'Teal'], 'after two clicks on the first Next and one on the second the galleries show ' + JSON.stringify(names()));
});
data.js
export const colours = [
{ name: 'Tomato', hex: '#ff6347', details: 'A warm red with a touch of orange.' },
{ name: 'Teal', hex: '#008080', details: 'A calm, deep blue-green.' },
{ name: 'Gold', hex: '#ffd700', details: 'A bright yellow with a warm glow.' }
];
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 a Previous button
The gallery can only move forward. Add a Previous button before Next. A click on it shows the previous colour. On the first colour it is disabled, with disabled={index === 0}, so index never goes below 0. Write a handlePreviousClick that only lowers index when it is above 0. Next and Show details must keep working.
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
handlePreviousClick is like handleNextClick: if (index > 0) { setIndex(index - 1); }.
Hint 2
Put <button onClick={handlePreviousClick} disabled={index === 0}>Previous</button> before the Next button.
Hint 3
A disabled button ignores clicks, and the check in the handler keeps index at 0 or above anyway.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.jsx
import { useState } from 'react';
import { colours } from './data.js';
export default function Gallery() {
const [index, setIndex] = useState(0);
const [showMore, setShowMore] = useState(false);
function handleNextClick() {
if (index < colours.length - 1) {
setIndex(index + 1);
} else {
setIndex(0);
}
}
// TODO: add handlePreviousClick and a Previous button before Next.
// Disable it on the first colour, so index never goes below 0.
function handleMoreClick() {
setShowMore(!showMore);
}
const colour = colours[index];
return (
<section>
<button onClick={handleNextClick}>Next</button>
<h2>{colour.name}</h2>
<p>
({index + 1} of {colours.length})
</p>
<div className="swatch" style={{ background: colour.hex }} />
<button onClick={handleMoreClick}>
{showMore ? 'Hide' : 'Show'} details
</button>
{showMore && <p>{colour.details}</p>}
</section>
);
}
main.test.jsx
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 Gallery from './main.jsx';
const name = () => screen.getByRole('heading', {level: 2}).textContent;
const button = (label) => screen.getByRole('button', {name: label});
test('Previous is disabled on the first colour', () => {
render(<Gallery />);
assert.equal(button('Previous').disabled, true, 'Previous can be clicked on the first colour');
});
test('Next, then Previous, shows the first colour again', async () => {
const user = userEvent.setup();
render(<Gallery />);
await user.click(button('Next'));
assert.equal(button('Previous').disabled, false, 'Previous is still disabled on the second colour');
await user.click(button('Previous'));
assert.equal(name(), 'Tomato', 'after Next and Previous the gallery shows ' + JSON.stringify(name()));
});
test('Next twice, then Previous, shows Teal, 2 of 3', async () => {
const user = userEvent.setup();
render(<Gallery />);
await user.click(button('Next'));
await user.click(button('Next'));
await user.click(button('Previous'));
assert.equal(name(), 'Teal', 'the gallery shows ' + JSON.stringify(name()));
const count = screen.getByText(/of 3/).textContent;
assert.equal(count, '(2 of 3)', 'the counter reads ' + JSON.stringify(count));
});
test('Show details still works', async () => {
const user = userEvent.setup();
render(<Gallery />);
await user.click(button('Show details'));
assert.ok(screen.getByText('A warm red with a touch of orange.'), 'the details of Tomato are not shown');
});
data.js
export const colours = [
{ name: 'Tomato', hex: '#ff6347', details: 'A warm red with a touch of orange.' },
{ name: 'Teal', hex: '#008080', details: 'A calm, deep blue-green.' },
{ name: 'Gold', hex: '#ffd700', details: 'A bright yellow with a warm glow.' }
];
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
Swapping the pair
import { useState } from 'react';
export default function Counter() {
const [setCount, count] = useState(0);
return <p>Count: {count}</p>;
}
What React prints
Functions are not valid as a React child.
Why, and the fix
useState always returns the value first and the setter second; the names do not change that. Here count holds the setter, a function, which React cannot show. Keep the order: const [count, setCount] = useState(0);.
Forgetting to call useState
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState;
return <p>Count: {count}</p>;
}
What React prints
TypeError: useState is not iterable
Why, and the fix
Without parentheses, useState is the function itself, which cannot be destructured into a pair, and the run stops with Uncaught TypeError (Chrome and Node wording; Firefox and Safari word it differently). Call it with the initial value: useState(0).
Older examples write React.useState. With the automatic JSX runtime nothing defines React for you, so the run stops with Uncaught ReferenceError. Import the Hook, import { useState } from 'react';, and call useState(0).
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
A local variable forgets
A gallery shows colours[index], and Next runs index = index + 1. Nothing happens on screen, for two reasons. Changing a local variable does not trigger a render: React does not know anything changed. And local variables do not persist between renders: when React renders the component again for any reason, let index = 0 runs again and starts over. A component needs two things to remember: data that is kept between renders, and a way to tell React to render again with the new data. useState gives both: a state variable that keeps the value, and a setter that stores a new value and triggers a render.
useState, its pair and its rules
const [index, setIndex] = useState(0) asks React to remember index. The argument is the initial value, used only on the first render. useState returns an array of exactly two items, the current value and its setter; the brackets are array destructuring. By convention the pair is named [something, setSomething]. A component can have as many state variables as it needs, such as index and showMore; React matches them by the order of the calls. useState is a Hook: a function starting with use, available only while React renders. Call Hooks at the top level of a component, never inside conditions, loops or nested functions.
Each instance has its own private state
State belongs to a component instance on the screen, not to the function or the file. Render <Gallery /> twice and you get two indexes: clicking Next in one leaves the other alone. A variable at the top of the module would be shared by both, and changing it would still not render either. State is also private: unlike props, the parent cannot read or change it, and Page does not even know whether Gallery has state. To keep two galleries in sync, you would move the state to their closest shared parent, as in lesson B1.4.