Skip to content
aviral gupta

// B5.5 · ~25 min · Beginner

Thinking in React

After this lesson you can turn a mockup into a React app in five steps, and decide what is state and which component owns it.

Lesson 5 of 6 in B5 Managing state

You will be able to

  • Break a UI into a component hierarchy and build a static version with props only
  • Find the minimal state and compute everything else from it
  • Put state in the closest common parent and pass setters down for inverse data flow
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B5.4: a key resets state. You click the counter twice, then Toggle. What do the buttons show?

    function Counter() {
      const [score, setScore] = useState(0);
      return <button onClick={() => setScore(score + 1)}>Score: {score}</button>;
    }
    
    function App() {
      const [isOn, setIsOn] = useState(false);
      return (
        <div>
          <Counter key={isOn ? 'on' : 'off'} />
          <button onClick={() => setIsOn(!isOn)}>Toggle</button>
        </div>
      );
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. A search box is built from state, but nothing handles typing. You type blue. What does the input hold?

    function SearchBar({ query }) {
      return <input aria-label="Search songs" value={query} />;
    }
    
    function Playlist() {
      const [query, setQuery] = useState('');
      return <SearchBar query={query} />;
    }
  3. Practice · Activity 3 of 7

    A playlist app has four pieces of data: the songs array (passed in as props), the search text, the Only liked checkbox and the list of visible songs. Which of them are state?

  4. Practice · Activity 4 of 7

    Match each step of thinking in React with what you do in it.

  5. Practice · Activity 5 of 7

    Playlist passes its setter down as onQueryChange. Fill in the event attribute that lets the input report each change.

    <input value={query} ____={e => onQueryChange(e.target.value)} />
    <input value={query} ={e => onQueryChange(e.target.value)} />
  6. Brain teaser · Activity 6 of 7

    Brain teaser. List copies the songs prop into its own state. You click Add song once. How many items does the list show?

    function List({ songs }) {
      const [items] = useState(songs);
      return (
        <ul>
          {items.map(song => <li key={song}>{song}</li>)}
        </ul>
      );
    }
    
    function App() {
      const [songs, setSongs] = useState(['Blue Hour', 'Salt']);
      return (
        <>
          <List songs={songs} />
          <button onClick={() => setSongs([...songs, 'Night Bus'])}>Add song</button>
        </>
      );
    }
  7. Apply · Activity 7 of 7

    Mini-task: a chore list with an "Only open chores" checkbox. In main.jsx, write a Chores component that owns one state variable, onlyOpen, a Filter component with the controlled checkbox, and a ChoreList that renders the chores it gets as props. The chores array has an id, a name and a done flag. Do not store the filtered chores: compute them in Chores and pass them down.

    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 filterable playlist, step by step

The comments mark the five steps. SongList and SongRow are the static part: they only read props. The search text and the checkbox are the only state, owned by their common parent, and the filtered list is computed in the render. The setters travel down as onQueryChange and onLikedOnlyChange. A run only mounts, so timers at the bottom of main.jsx type and click, one step every 50 ms, and print the list after each change. In the browser the page stays live: try it yourself.

main.jsx

import { useState } from 'react';

const SONGS = [
  { id: 1, title: 'Blue Hour', artist: 'Mira', liked: true },
  { id: 2, title: 'Paper Boats', artist: 'Tom', liked: false },
  { id: 3, title: 'Night Bus', artist: 'Mira', liked: true },
  { id: 4, title: 'Salt', artist: 'Jun', liked: false }
];

// Steps 3 and 4: the minimal state is the search text and the checkbox.
// Both are used by SearchBar and by SongList, so they live in their parent.
export default function FilterablePlaylist() {
  const [query, setQuery] = useState('');
  const [likedOnly, setLikedOnly] = useState(false);

  // Everything else is computed on demand, not stored.
  const shown = SONGS.filter(
    song =>
      song.title.toLowerCase().includes(query.toLowerCase()) &&
      (!likedOnly || song.liked)
  );

  return (
    <div>
      <SearchBar
        query={query}
        likedOnly={likedOnly}
        onQueryChange={setQuery}
        onLikedOnlyChange={setLikedOnly}
      />
      <SongList songs={shown} />
    </div>
  );
}

// Step 5: inverse data flow. The setters come down as props.
function SearchBar({ query, likedOnly, onQueryChange, onLikedOnlyChange }) {
  return (
    <form>
      <input
        aria-label="Search songs"
        value={query}
        onChange={e => onQueryChange(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={likedOnly}
          onChange={e => onLikedOnlyChange(e.target.checked)}
        />
        Only liked songs
      </label>
    </form>
  );
}

// Step 2: the static parts only read their props.
function SongList({ songs }) {
  return (
    <ul>
      {songs.map(song => <SongRow key={song.id} song={song} />)}
    </ul>
  );
}

function SongRow({ song }) {
  return <li>{song.title}</li>;
}

// A run only mounts. Act like a user, one step every 50 ms.
const search = value => {
  const input = document.querySelector('input');
  const win = input.ownerDocument.defaultView;
  Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value').set.call(input, value);
  input.dispatchEvent(new win.Event('input', { bubbles: true }));
};
const print = () =>
  console.log([...document.querySelectorAll('li')].map(li => li.textContent).join(', '));
const plan = [
  print,
  () => search('o'),
  print,
  () => document.querySelector('input[type=checkbox]').click(),
  print,
  () => search(''),
  print
];
plan.forEach((step, i) => setTimeout(step, 50 * (i + 1)));

Run it with

npm run dev

Output

Blue Hour, Paper Boats, Night Bus, Salt
Blue Hour, Paper Boats
Blue Hour
Blue Hour, Night Bus
  • Typing "o" kept the two titles that contain an o. The list was recomputed from query and SONGS, not edited.
  • The checkbox then narrowed it to Blue Hour: both state values are used by the same filter.
  • Clearing the search text kept the checkbox: Blue Hour and Night Bus, the liked songs. Nothing had to be put back by hand, because nothing was stored besides the two values.
  • SearchBar owns nothing. It shows its props and calls the setters it received.
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

Add the inverse data flow

FilterableSongs owns the state and filters the list correctly, but you cannot type in the search box or check the box: SearchBar reads query and likedOnly and has no way to change them. Add the missing step 5. Pass the two setters down from FilterableSongs, with the names onQueryChange and onLikedOnlyChange, and call them from the inputs' onChange handlers. Leave the state and the filtering as they are.

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
  1. Hint 1

    A child cannot change a prop. The parent has to pass a function down.

  2. Hint 2

    FilterableSongs can pass setQuery and setLikedOnly as props.

  3. Hint 3

    For the text box call onQueryChange(e.target.value); for the checkbox call onLikedOnlyChange(e.target.checked).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

const SONGS = [
  { id: 1, title: 'Blue Hour', artist: 'Mira', liked: true },
  { id: 2, title: 'Paper Boats', artist: 'Tom', liked: false },
  { id: 3, title: 'Night Bus', artist: 'Mira', liked: true },
  { id: 4, title: 'Salt', artist: 'Jun', liked: false }
];

export default function FilterableSongs() {
  const [query, setQuery] = useState('');
  const [likedOnly, setLikedOnly] = useState(false);
  const shown = SONGS.filter(
    song =>
      song.title.toLowerCase().includes(query.toLowerCase()) &&
      (!likedOnly || song.liked)
  );
  return (
    <div>
      <SearchBar
        query={query}
        likedOnly={likedOnly}
        onQueryChange={setQuery}
        onLikedOnlyChange={setLikedOnly}
      />
      <SongList songs={shown} />
    </div>
  );
}

function SearchBar({ query, likedOnly, onQueryChange, onLikedOnlyChange }) {
  return (
    <form>
      <input
        aria-label="Search songs"
        value={query}
        onChange={e => onQueryChange(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={likedOnly}
          onChange={e => onLikedOnlyChange(e.target.checked)}
        />
        Only liked songs
      </label>
    </form>
  );
}

function SongList({ songs }) {
  return (
    <ul>
      {songs.map(song => <li key={song.id}>{song.title}</li>)}
    </ul>
  );
}
Run it on your computer

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';

const SONGS = [
  { id: 1, title: 'Blue Hour', artist: 'Mira', liked: true },
  { id: 2, title: 'Paper Boats', artist: 'Tom', liked: false },
  { id: 3, title: 'Night Bus', artist: 'Mira', liked: true },
  { id: 4, title: 'Salt', artist: 'Jun', liked: false }
];

export default function FilterableSongs() {
  const [query, setQuery] = useState('');
  const [likedOnly, setLikedOnly] = useState(false);
  const shown = SONGS.filter(
    song =>
      song.title.toLowerCase().includes(query.toLowerCase()) &&
      (!likedOnly || song.liked)
  );
  return (
    <div>
      <SearchBar query={query} likedOnly={likedOnly} />
      <SongList songs={shown} />
    </div>
  );
}

function SearchBar({ query, likedOnly }) {
  return (
    <form>
      <input aria-label="Search songs" value={query} />
      <label>
        <input type="checkbox" checked={likedOnly} />
        Only liked songs
      </label>
    </form>
  );
}

function SongList({ songs }) {
  return (
    <ul>
      {songs.map(song => <li key={song.id}>{song.title}</li>)}
    </ul>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, fireEvent} from '@testing-library/react';
import FilterableSongs from './main.jsx';

const titles = () => screen.queryAllByRole('listitem').map((li) => li.textContent).join(', ');
const search = (value) => fireEvent.change(screen.getByRole('textbox', {name: 'Search songs'}), {target: {value}});
const liked = () => fireEvent.click(screen.getByRole('checkbox', {name: 'Only liked songs'}));
const says = (what) => 'the list shows ' + JSON.stringify(titles()) + ' ' + what;

test('Typing in the search box filters the list', () => {
  render(<FilterableSongs />);
  search('o');
  assert.equal(titles(), 'Blue Hour, Paper Boats', says('after typing "o"'));
});

test('Clearing the search box shows every song again', () => {
  render(<FilterableSongs />);
  search('o');
  search('');
  assert.equal(titles(), 'Blue Hour, Paper Boats, Night Bus, Salt', says('after clearing the search box'));
});

test('The checkbox keeps only the liked songs', () => {
  render(<FilterableSongs />);
  liked();
  assert.equal(titles(), 'Blue Hour, Night Bus', says('after checking Only liked songs'));
  assert.equal(screen.getByRole('checkbox', {name: 'Only liked songs'}).checked, true, 'the checkbox is checked');
});

test('Search and checkbox work together', () => {
  render(<FilterableSongs />);
  search('o');
  liked();
  assert.equal(titles(), 'Blue Hour', says('after typing "o" and checking Only liked songs'));
});

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

Remove the redundant state

This version keeps a third state variable, visible, and updates it in each handler. Check Only liked songs, then type a search: the checkbox is forgotten. Type a search, check and uncheck the box: the search is forgotten. The visible songs can be computed, so they are not state. Delete the visible state and its updates, and compute visible in the render from query and likedOnly. Keep the inputs and their handlers.

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
  1. Hint 1

    Which of the three state variables can be worked out from the other two?

  2. Hint 2

    The handlers should only set query and likedOnly.

  3. Hint 3

    const visible = SONGS.filter(...) in the component body, using query and likedOnly together.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

const SONGS = [
  { id: 1, title: 'Blue Hour', artist: 'Mira', liked: true },
  { id: 2, title: 'Paper Boats', artist: 'Tom', liked: false },
  { id: 3, title: 'Night Bus', artist: 'Mira', liked: true },
  { id: 4, title: 'Salt', artist: 'Jun', liked: false }
];

export default function FilterableSongs() {
  const [query, setQuery] = useState('');
  const [likedOnly, setLikedOnly] = useState(false);

  const visible = SONGS.filter(
    song =>
      song.title.toLowerCase().includes(query.toLowerCase()) &&
      (!likedOnly || song.liked)
  );

  return (
    <div>
      <form>
        <input
          aria-label="Search songs"
          value={query}
          onChange={e => setQuery(e.target.value)}
        />
        <label>
          <input
            type="checkbox"
            checked={likedOnly}
            onChange={e => setLikedOnly(e.target.checked)}
          />
          Only liked songs
        </label>
      </form>
      <ul>
        {visible.map(song => <li key={song.id}>{song.title}</li>)}
      </ul>
    </div>
  );
}
Run it on your computer

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';

const SONGS = [
  { id: 1, title: 'Blue Hour', artist: 'Mira', liked: true },
  { id: 2, title: 'Paper Boats', artist: 'Tom', liked: false },
  { id: 3, title: 'Night Bus', artist: 'Mira', liked: true },
  { id: 4, title: 'Salt', artist: 'Jun', liked: false }
];

export default function FilterableSongs() {
  const [query, setQuery] = useState('');
  const [likedOnly, setLikedOnly] = useState(false);
  const [visible, setVisible] = useState(SONGS);

  function handleQueryChange(value) {
    setQuery(value);
    setVisible(SONGS.filter(song => song.title.toLowerCase().includes(value.toLowerCase())));
  }

  function handleLikedOnlyChange(checked) {
    setLikedOnly(checked);
    setVisible(checked ? visible.filter(song => song.liked) : SONGS);
  }

  return (
    <div>
      <form>
        <input
          aria-label="Search songs"
          value={query}
          onChange={e => handleQueryChange(e.target.value)}
        />
        <label>
          <input
            type="checkbox"
            checked={likedOnly}
            onChange={e => handleLikedOnlyChange(e.target.checked)}
          />
          Only liked songs
        </label>
      </form>
      <ul>
        {visible.map(song => <li key={song.id}>{song.title}</li>)}
      </ul>
    </div>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, fireEvent} from '@testing-library/react';
import FilterableSongs from './main.jsx';

const titles = () => screen.queryAllByRole('listitem').map((li) => li.textContent).join(', ');
const search = (value) => fireEvent.change(screen.getByRole('textbox', {name: 'Search songs'}), {target: {value}});
const liked = () => fireEvent.click(screen.getByRole('checkbox', {name: 'Only liked songs'}));
const says = (what) => 'the list shows ' + JSON.stringify(titles()) + ' ' + what;

test('Searching filters by title', () => {
  render(<FilterableSongs />);
  search('o');
  assert.equal(titles(), 'Blue Hour, Paper Boats', says('after typing "o"'));
});

test('Checking the box and then searching keeps both filters', () => {
  render(<FilterableSongs />);
  liked();
  search('o');
  assert.equal(titles(), 'Blue Hour', says('after checking Only liked songs and typing "o"'));
});

test('Unchecking the box keeps the search text', () => {
  render(<FilterableSongs />);
  search('o');
  liked();
  liked();
  assert.equal(titles(), 'Blue Hour, Paper Boats', says('after typing "o", checking and unchecking Only liked songs'));
});

test('Clearing the search box keeps the checkbox', () => {
  render(<FilterableSongs />);
  liked();
  search('o');
  search('');
  assert.equal(titles(), 'Blue Hour, Night Bus', says('after checking the box, typing "o" and clearing it'));
});

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

A controlled input with nothing to change its state

import { useState } from 'react';

export default function Playlist() {
  const [query, setQuery] = useState('');
  return <input aria-label="Search songs" value={query} />;
}

What React prints

You provided a `value` prop to a form field without an `onChange` handler.

Why, and the fix

The input is pinned to query, and nothing in the program can change query, so React renders a read-only field. Add the last step: pass the setter down and call it, <input value={query} onChange={e => setQuery(e.target.value)} />. If the input is in a child, the parent passes setQuery down as a prop.

A prop passed under another name

const SONGS = [
  { id: 1, title: 'Blue Hour', artist: 'Mira', liked: true },
  { id: 2, title: 'Paper Boats', artist: 'Tom', liked: false },
  { id: 3, title: 'Night Bus', artist: 'Mira', liked: true },
  { id: 4, title: 'Salt', artist: 'Jun', liked: false }
];

export default function Playlist() {
  return <SongList items={SONGS} />;
}

function SongList({ songs }) {
  return (
    <ul>
      {songs.map(song => <li key={song.id}>{song.title}</li>)}
    </ul>
  );
}

What React prints

TypeError: Cannot read properties of undefined (reading 'map')

Why, and the fix

Playlist passes items, but SongList reads songs, and a prop that was not passed is undefined (Chrome and Node wording; Firefox and Safari word it differently). When you build the static version, give parent and child the same prop name: <SongList songs={SONGS} />.

A static list without keys

const SONGS = [
  { id: 1, title: 'Blue Hour', artist: 'Mira', liked: true },
  { id: 2, title: 'Paper Boats', artist: 'Tom', liked: false },
  { id: 3, title: 'Night Bus', artist: 'Mira', liked: true },
  { id: 4, title: 'Salt', artist: 'Jun', liked: false }
];

export default function Playlist() {
  return <SongList songs={SONGS} />;
}

function SongList({ songs }) {
  return (
    <ul>
      {songs.map(song => <li>{song.title}</li>)}
    </ul>
  );
}

What React prints

Each child in a list should have a unique "key" prop.

Why, and the fix

Building the static version includes the lists. Every item that comes from an array needs a key that is stable and unique among its siblings, such as the id from the data model: <li key={song.id}>.

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.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Five steps, in order

Start with a mockup. Step 1: draw a box around every component and name it; each component should match one piece of your data model. Step 2: build a static version that only passes data down with props, with no state at all. Step 3: find the minimal state. Step 4: decide which component owns it. Step 5: add inverse data flow, so that inputs can change that state. Doing the steps in order keeps each question small. Data that flows from the top component down through props is called one-way data flow.

State is the minimum; the rest is computed

Keep state as small as possible and compute everything else on demand. Ask three questions of each piece of data. Does it stay unchanged over time? Then it is not state. Is it passed in from a parent via props? Then it is not state. Can you compute it from existing state or props? Then it is definitely not state. In a filterable list, the songs come as props; the search text and the checkbox are state; the filtered list is computed in the render, not stored. A stored copy would have to be kept in sync by hand.

The owner is the closest common parent

For each piece of state, find every component that renders something based on it, and then their closest common parent. Often the state lives right there. Here the search text and the checkbox are used by SearchBar and by SongList, so FilterablePlaylist owns them. But owning is not enough: a child cannot change a prop, so the parent passes the setters down as props, such as onQueryChange={setQuery}, and the child calls them from its onChange handlers. Data flows down in props, and changes flow up through calls: inverse data flow.

Sources

Last reviewed October 5, 2026