Skip to content
aviral gupta

// B5.3 · ~30 min · Beginner

Sharing state between components

After this lesson you can make two components change together: their state moves up to a common parent, which passes it down with handlers.

Lesson 3 of 6 in B5 Managing state

You will be able to

  • Lift state that two components share to their closest common parent and pass it down as props
  • Pass event handlers down, so that children can ask the parent to change its state
  • Tell controlled from uncontrolled components, and keep one owner for each piece of state
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B5.2: keep an index, not a copy. You click the third button once. What do the buttons show?

    function Planets() {
      const planets = ['Mercury', 'Venus', 'Earth'];
      const [selectedIndex, setSelectedIndex] = useState(0);
    
      return (
        <>
          {planets.map((name, index) => (
            <button key={name} onClick={() => setSelectedIndex(index)}>
              {index === selectedIndex ? '> ' + name : name}
            </button>
          ))}
        </>
      );
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. Each Panel keeps its own isActive state. You click Show in Hours, then Show in Address. Which panels are open?

    function Panel({ title, children }) {
      const [isActive, setIsActive] = useState(false);
      return (
        <section>
          <h3>{title}</h3>
          {isActive ? <p>{children}</p> : <button onClick={() => setIsActive(true)}>Show</button>}
        </section>
      );
    }
    
    function Accordion() {
      return (
        <>
          <Panel title="Hours">Open daily from 8 to 18.</Panel>
          <Panel title="Address">Mill Lane 4, near the station.</Panel>
        </>
      );
    }
  3. Practice · Activity 3 of 7

    Accordion keeps const [activeIndex, setActiveIndex] = useState(0);. Fill in the function the second Panel's onShow calls, so that its Show button opens it.

    <Panel title="Address" isActive={activeIndex === 1} onShow={() => ____(1)}>
    <Panel title="Address" isActive={activeIndex === 1} onShow={() => (1)}>
  4. Practice · Activity 4 of 7

    Match each term with what it means in this lesson.

  5. Practice · Activity 5 of 7

    Now Accordion owns activeIndex. You click Show in Address, then Show in Hours. Which panels are open?

    function Panel({ title, children, isActive, onShow }) {
      return (
        <section>
          <h3>{title}</h3>
          {isActive ? <p>{children}</p> : <button onClick={onShow}>Show</button>}
        </section>
      );
    }
    
    function Accordion() {
      const [activeIndex, setActiveIndex] = useState(0);
      return (
        <>
          <Panel title="Hours" isActive={activeIndex === 0} onShow={() => setActiveIndex(0)}>
            Open daily from 8 to 18.
          </Panel>
          <Panel title="Address" isActive={activeIndex === 1} onShow={() => setActiveIndex(1)}>
            Mill Lane 4, near the station.
          </Panel>
        </>
      );
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Both onShow props were copied from the same line. You click Show in Address, then Show in Hours. Which panels are open?

    function Panel({ title, children, isActive, onShow }) {
      return (
        <section>
          <h3>{title}</h3>
          {isActive ? <p>{children}</p> : <button onClick={onShow}>Show</button>}
        </section>
      );
    }
    
    function Accordion() {
      const [activeIndex, setActiveIndex] = useState(0);
      return (
        <>
          <Panel title="Hours" isActive={activeIndex === 0} onShow={() => setActiveIndex(1)}>
            Open daily from 8 to 18.
          </Panel>
          <Panel title="Address" isActive={activeIndex === 1} onShow={() => setActiveIndex(1)}>
            Mill Lane 4, near the station.
          </Panel>
        </>
      );
    }
  7. Apply · Activity 7 of 7

    Mini-task: tabs that share one state. In main.jsx, write a TabButton component that takes label, isSelected and onSelect props and renders a <button> with aria-pressed={isSelected}. Write a Tabs component that renders two TabButtons, Menu and Hours, and a <p> with the selected tab's text. Tabs owns the state: which tab is selected. A click on a TabButton must select it through its onSelect prop.

    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

An accordion that shows one panel at a time

Accordion owns activeIndex and passes each Panel two props: isActive, computed from activeIndex, and onShow, a function that sets it. Panel has no state of its own; it is controlled by its parent. A run only mounts, so timers at the bottom of main.jsx click the only Show button, then print which panels are open. In the browser the page stays live: click Show yourself.

main.jsx

import { useState } from 'react';

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <>
      <h2>Mill Lane Bakery</h2>
      <Panel
        title="Hours"
        isActive={activeIndex === 0}
        onShow={() => setActiveIndex(0)}
      >
        Open daily from 8 to 18.
      </Panel>
      <Panel
        title="Address"
        isActive={activeIndex === 1}
        onShow={() => setActiveIndex(1)}
      >
        Mill Lane 4, near the station.
      </Panel>
    </>
  );
}

function Panel({ title, children, isActive, onShow }) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={onShow}>Show</button>
      )}
    </section>
  );
}

// A run only mounts. Act like a user: show the second panel.
setTimeout(() => {
  document.querySelector('button').click();
  setTimeout(() => {
    const open = [...document.querySelectorAll('section')].filter(s => s.querySelector('p'));
    console.log('Open now:', open.map(s => s.querySelector('h3').textContent).join(', '));
  }, 50);
}, 50);

Run it with

npm run dev

Output

Open now: Address
<!-- #root -->
<div id="root"><h2>Mill Lane Bakery</h2><section class="panel"><h3>Hours</h3><button>Show</button></section><section class="panel"><h3>Address</h3><p>Mill Lane 4, near the station.</p></section></div>
  • At first activeIndex is 0, so Hours shows its text and Address shows the only Show button.
  • The click ran Address' onShow, which Accordion wrote: setActiveIndex(1). Accordion rendered again, and both Panels got new isActive props.
  • Hours closed without any code in Hours: its isActive is now activeIndex === 0, which is false.
  • With a third Panel, Accordion would pass isActive={activeIndex === 2} and onShow={() => setActiveIndex(2)}; Panel stays the same.
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

Keep two fields in sync

GreetingCard has two NameField components, one for the card and one for the envelope. Each keeps its own name state, so typing in one leaves the other empty. Make them stay in sync: typing in either field must show the same text in both. Lift the state into GreetingCard and pass value and onChange down to each NameField, which then has no state of its own.

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

    Move const [name, setName] = useState(''); and handleChange from NameField into GreetingCard.

  2. Hint 2

    Render <NameField label="…" value={name} onChange={handleChange} /> twice.

  3. Hint 3

    NameField takes { label, value, onChange } and renders <input value={value} onChange={onChange} />.

Show a solution

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

import { useState } from 'react';

export default function GreetingCard() {
  const [name, setName] = useState('');

  function handleChange(e) {
    setName(e.target.value);
  }

  return (
    <>
      <NameField label="Name on the card" value={name} onChange={handleChange} />
      <NameField label="Name on the envelope" value={name} onChange={handleChange} />
    </>
  );
}

function NameField({ label, value, onChange }) {
  return (
    <label>
      {label}{' '}
      <input value={value} onChange={onChange} />
    </label>
  );
}
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';

export default function GreetingCard() {
  return (
    <>
      <NameField label="Name on the card" />
      <NameField label="Name on the envelope" />
    </>
  );
}

function NameField({ label }) {
  const [name, setName] = useState('');

  function handleChange(e) {
    setName(e.target.value);
  }

  return (
    <label>
      {label}{' '}
      <input value={name} onChange={handleChange} />
    </label>
  );
}

main.test.jsx

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

const card = () => screen.getByLabelText('Name on the card');
const envelope = () => screen.getByLabelText('Name on the envelope');

test('Both fields start empty', () => {
  render(<GreetingCard />);
  assert.equal(card().value, '', 'at the start the card field holds ' + JSON.stringify(card().value));
  assert.equal(envelope().value, '', 'at the start the envelope field holds ' + JSON.stringify(envelope().value));
});

test('Typing on the card fills the envelope', () => {
  render(<GreetingCard />);
  fireEvent.change(card(), {target: {value: 'Ada'}});
  assert.equal(card().value, 'Ada', 'the card field holds ' + JSON.stringify(card().value));
  assert.equal(envelope().value, 'Ada', 'after typing Ada on the card the envelope field holds ' + JSON.stringify(envelope().value));
});

test('Typing on the envelope fills the card', () => {
  render(<GreetingCard />);
  fireEvent.change(card(), {target: {value: 'Ada'}});
  fireEvent.change(envelope(), {target: {value: 'Grace'}});
  assert.equal(card().value, 'Grace', 'after typing Grace on the envelope the card field holds ' + JSON.stringify(card().value));
  assert.equal(envelope().value, 'Grace', 'the envelope field holds ' + JSON.stringify(envelope().value));
});

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

A search box that filters a list

FilterableList renders a SearchBar and a List of fruits. SearchBar keeps the query in its own state, so List never learns it and always shows all six fruits. Lift query into FilterableList, pass it and an onChange handler to SearchBar, and pass List only the fruits whose name contains the query, ignoring upper and lower case. An empty search shows every fruit.

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

    FilterableList is the closest common parent of SearchBar and List, so the query state belongs there.

  2. Hint 2

    SearchBar takes { query, onChange } and renders <input value={query} onChange={onChange} />.

  3. Hint 3

    const visible = fruits.filter(item => item.toLowerCase().includes(query.toLowerCase())); and pass items={visible}.

Show a solution

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

import { useState } from 'react';

const fruits = ['Strawberry', 'Blueberry', 'Banana', 'Gooseberry', 'Cherry', 'Raspberry'];

export default function FilterableList() {
  const [query, setQuery] = useState('');
  const visible = fruits.filter(item =>
    item.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <>
      <SearchBar query={query} onChange={e => setQuery(e.target.value)} />
      <hr />
      <List items={visible} />
    </>
  );
}

function SearchBar({ query, onChange }) {
  return (
    <label>
      Search:{' '}
      <input value={query} onChange={onChange} />
    </label>
  );
}

function List({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item}>{item}</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 fruits = ['Strawberry', 'Blueberry', 'Banana', 'Gooseberry', 'Cherry', 'Raspberry'];

export default function FilterableList() {
  return (
    <>
      <SearchBar />
      <hr />
      <List items={fruits} />
    </>
  );
}

function SearchBar() {
  const [query, setQuery] = useState('');
  return (
    <label>
      Search:{' '}
      <input value={query} onChange={e => setQuery(e.target.value)} />
    </label>
  );
}

function List({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item}>{item}</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 FilterableList from './main.jsx';

const search = () => screen.getByLabelText('Search:');
const shown = () => screen.queryAllByRole('listitem').map((li) => li.textContent).join(', ');

test('All six fruits show at the start', () => {
  render(<FilterableList />);
  assert.equal(shown(), 'Strawberry, Blueberry, Banana, Gooseberry, Cherry, Raspberry', 'at the start the list shows ' + JSON.stringify(shown()));
});

test('Typing berry keeps only the berries', () => {
  render(<FilterableList />);
  fireEvent.change(search(), {target: {value: 'berry'}});
  assert.equal(search().value, 'berry', 'the search field holds ' + JSON.stringify(search().value));
  assert.equal(shown(), 'Strawberry, Blueberry, Gooseberry, Raspberry', 'after typing berry the list shows ' + JSON.stringify(shown()));
});

test('The search ignores upper and lower case', () => {
  render(<FilterableList />);
  fireEvent.change(search(), {target: {value: 'CHERRY'}});
  assert.equal(shown(), 'Cherry', 'after typing CHERRY the list shows ' + JSON.stringify(shown()));
});

test('Clearing the search shows every fruit again', () => {
  render(<FilterableList />);
  fireEvent.change(search(), {target: {value: 'ban'}});
  assert.equal(shown(), 'Banana', 'after typing ban the list shows ' + JSON.stringify(shown()));
  fireEvent.change(search(), {target: {value: ''}});
  assert.equal(shown(), 'Strawberry, Blueberry, Banana, Gooseberry, Cherry, Raspberry', 'after clearing the search the list shows ' + JSON.stringify(shown()));
});

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

Calling a handler prop the parent never passed

import { useState } from 'react';

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <>
      <Panel title="Hours" isActive={activeIndex === 0}>
        Open daily from 8 to 18.
      </Panel>
      <Panel title="Address" isActive={activeIndex === 1}>
        Mill Lane 4, near the station.
      </Panel>
    </>
  );
}

function Panel({ title, children, isActive, onShow }) {
  return (
    <section>
      <h3>{title}</h3>
      {isActive ? <p>{children}</p> : <button onClick={() => onShow()}>Show</button>}
    </section>
  );
}

// A run only mounts. Act like a user: click Show.
setTimeout(() => document.querySelector('button').click(), 50);

What React prints

TypeError: onShow is not a function

Why, and the fix

Accordion passes isActive but no onShow, so the prop is undefined, and calling it throws when the button is clicked (Chrome and Node wording; Firefox and Safari word it differently). Pass the handler from the parent: onShow={() => setActiveIndex(1)}. Lifting state always means passing both: the value and a way to change it.

A handler that expects an event but receives a value

import { useState } from 'react';

export default function FilterableList() {
  const [query, setQuery] = useState('');
  return (
    <>
      <SearchBar query={query} onQueryChange={e => setQuery(e.target.value)} />
      <p>Searching for: {query}</p>
    </>
  );
}

function SearchBar({ query, onQueryChange }) {
  return <input value={query} onChange={e => onQueryChange(e.target.value)} />;
}

// A run only mounts. Act like a user: type b into the field.
setTimeout(() => {
  const input = document.querySelector('input');
  Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value').set.call(input, 'b');
  input.dispatchEvent(new window.Event('input', { bubbles: true }));
}, 50);

What React prints

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

Why, and the fix

SearchBar already calls onQueryChange(e.target.value), so the parent's handler receives the string 'b', and 'b'.target is undefined (Chrome and Node wording; Firefox and Safari word it differently). Agree on one shape: either the child passes the value and the parent writes onQueryChange={setQuery}, or the child passes the event on with onChange={onQueryChange}.

Lifted state that starts as undefined

import { useState } from 'react';

export default function FilterableList() {
  const [query, setQuery] = useState();
  return (
    <>
      <SearchBar query={query} onQueryChange={setQuery} />
      <p>Searching for: {query}</p>
    </>
  );
}

function SearchBar({ query, onQueryChange }) {
  return <input value={query} onChange={e => onQueryChange(e.target.value)} />;
}

// A run only mounts. Act like a user: type b into the field.
setTimeout(() => {
  const input = document.querySelector('input');
  Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value').set.call(input, 'b');
  input.dispatchEvent(new window.Event('input', { bubbles: true }));
}, 50);

What React prints

A component is changing an uncontrolled input to be controlled.

Why, and the fix

useState() with no argument starts query as undefined, so the first render passes value={undefined}, which React treats as an uncontrolled input. The first keystroke sets a string, and React warns. Start the lifted state with a value of the right type: useState('').

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

Lift shared state to the closest common parent

Two Panels that each keep their own isActive state are independent: opening one does not close the other. To make them change together, lift their state up in three steps. Remove the state from the children and read isActive from props instead. Pass hardcoded values from the closest common parent, to see that the props work. Then add state to that parent. Lifting often changes what you store: only one panel may be open, so the parent keeps one number, activeIndex, instead of two booleans, and passes isActive={activeIndex === 0} to the first panel.

Pass a handler down; the child asks, the parent changes

State is private to the component that declares it, so a Panel cannot call setActiveIndex: that function lives inside Accordion. The parent passes a handler down as a prop as well, onShow={() => setActiveIndex(1)}, and the Panel calls it from its button: <button onClick={onShow}>. The click runs the parent's function, the parent's state changes, and React renders the parent again with new props for every child. A child may also pass a value up, as in onSearch(e.target.value); then the parent's handler must expect a value, not an event.

Controlled, uncontrolled, and one owner for each state

react.dev calls a component uncontrolled when it keeps the important information in its own local state, like the first Panel, whose parent cannot open or close it. It is controlled when that information comes from props, so the parent fully specifies its behaviour. Uncontrolled components are easier to drop in; controlled ones are more flexible but need more props. These are not strict terms: most components mix both. For each piece of state, choose one component that owns it, a single source of truth. Do not copy shared state into two components; lift it and pass it down.

Sources

Last reviewed October 5, 2026