Skip to Content
React in a Week
Day 4 · State & escape hatches

Sharing state between components

Lift state up to a common parent so that two components always stay in sync.

Sometimes two components need to change together. Picture an accordion: when you open one panel, the others should close. If each panel keeps its own isOpen state, they have no way to know about each other.

The fix is called lifting state up: remove the state from the children, move it to their closest common parent, and pass it back down through props. It is the most common thing you will do with state in React.

The problem: independent state

Here each Panel owns its own isActive state. Click "Show" on both. They both open, because neither knows the other exists.

Live code· Two independent panels

The goal: only one panel open at a time.

Lifting state up, in three steps

Step 1. Remove state from the child. Delete the useState line from Panel and receive isActive as a prop instead. Now the parent decides.

Step 2. Pass hard-coded data from the parent. In Accordion, pass isActive={true} or isActive={false} to each Panel to check that the props work.

Step 3. Add state to the parent. Store which panel is open as a single number, activeIndex. Pass a callback so a panel can ask the parent to change it.

Live code· Lifted state: one panel at a time

Each Panel is now "dumb": it shows what it is told and reports clicks through onShow. The parent is the single source of truth for which panel is open.

Controlled and uncontrolled components

A component with its own local state is uncontrolled: its parent can't influence it. The first Panel was uncontrolled. A component whose important data comes from props is controlled: the parent fully decides how it behaves. The second Panel is controlled.

You met the same idea with form inputs on Day 3. An <input value={text} onChange={...} /> is a controlled input. Uncontrolled components are easier to drop in, and controlled components are more flexible. When you build a component, decide which values should be props (controlled) and which should be local state (uncontrolled).

Quick check

You lifted isActive from Panel into Accordion. How does a Panel now open itself?

Project: a FilterBar for the Reading List

Your Reading List needs filter buttons: All, Want, Reading and Done. Two components care about the filter:

  • FilterBar shows the buttons and highlights the active one.
  • BookList only shows matching books.

So the filter state lives in their shared parent, App. The filtered list is derived during render, not stored.

Live code· Reading List: FilterBar

FilterBar is fully controlled. It doesn't know how filtering works; it shows filter and calls onFilterChange. The aria-pressed attribute tells screen readers which button is on.

Try it

Show a count on each filter button

Make each button show how many books match it, like "Want (2)". FilterBar needs the books to count them. Should the counts be new state?

Show solution

No new state: the counts can be calculated from books. Pass books to FilterBar and count during render.

FilterBar.js
const FILTERS = [
  { value: 'all', label: 'All' },
  { value: 'want', label: 'Want' },
  { value: 'reading', label: 'Reading' },
  { value: 'done', label: 'Done' },
];

export function FilterBar({ books, filter, onFilterChange }) {
  return (
    <div className="filter-bar" role="group" aria-label="Filter books">
      {FILTERS.map((f) => {
        const count =
          f.value === 'all' ? books.length : books.filter((b) => b.status === f.value).length;
        return (
          <button
            key={f.value}
            aria-pressed={filter === f.value}
            className={filter === f.value ? 'active' : ''}
            onClick={() => onFilterChange(f.value)}
          >
            {f.label} ({count})
          </button>
        );
      })}
    </div>
  );
}

In App, render <FilterBar books={books} filter={filter} onFilterChange={setFilter} />.

Day 4 progress

0/10

On this page