EduFlow
React Fundamentals
0%

Section 3 · State & Events

Lifting State Up

18 min3 min readCode playground

Lesson at a glance

Share state between sibling components by moving it to their closest common parent, passing values down and change handlers up, so the app keeps a single source of truth.

  1. 1The problem: siblings can’t share
  2. 2The fix: lift it to the common parent
  3. 3Data down, events up
  4. 4Signs you need to lift
  5. 5As low as possible, as high as necessary

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

You now have an add-movie form, a movie list and filter buttons. Each works alone. Put them on one screen and they don’t talk to each other: the form can’t add to a list that lives in another component, and the list doesn’t know which filter button is pressed. Components can’t reach into each other’s state. That’s deliberate, and the fix is a pattern you’ll use in every React app you build.

The problem: siblings can’t share

Here’s the filter bar as you might first write it:

JSX
function FilterBar() {
  const [filter, setFilter] = useState('all');
  return (
    <div>
      <button aria-pressed={filter === 'all'} onClick={() => setFilter('all')}>All</button>
      <button aria-pressed={filter === 'to-watch'} onClick={() => setFilter('to-watch')}>To watch</button>
      <button aria-pressed={filter === 'watched'} onClick={() => setFilter('watched')}>Watched</button>
    </div>
  );
}

The buttons highlight correctly, but MovieList is a sibling. It has no access to filter, so it shows everything no matter what you click. State is private to the component that declares it.

The fix: lift it to the common parent

Find the closest component that’s a parent of everything that needs the state. For the filter, that’s App, the parent of both FilterBar and MovieList. Move the state there, then:

  1. pass the value down to everyone who reads it, and
  2. pass a function down to everyone who changes it.
src/App.jsx
export default function App() {
  const [movies, setMovies] = useState(INITIAL_MOVIES);
  const [filter, setFilter] = useState('all');

  const visible = movies.filter((m) =>
    filter === 'all' ? true : filter === 'watched' ? m.watched : !m.watched,
  );

  function handleAdd(title) {
    setMovies([...movies, { id: crypto.randomUUID(), title, watched: false }]);
  }

  return (
    <main>
      <Header movies={movies} />
      <AddMovieForm onAdd={handleAdd} />
      <FilterBar value={filter} onChange={setFilter} />
      <MovieList movies={visible} />
    </main>
  );
}

function FilterBar({ value, onChange }) {
  return (
    <div role="group" aria-label="Filter movies">
      <button aria-pressed={value === 'all'} onClick={() => onChange('all')}>All</button>
      <button aria-pressed={value === 'to-watch'} onClick={() => onChange('to-watch')}>To watch</button>
      <button aria-pressed={value === 'watched'} onClick={() => onChange('watched')}>Watched</button>
    </div>
  );
}

FilterBar no longer has state. It displays value and reports clicks through onChange. A component like this is called controlled: its parent decides what it shows. It’s now also easier to test and reuse, because everything it does is visible in its props.

App owns movies and filter state. It passes movies to Header, onAdd to AddMovieForm, value and onChange to FilterBar, and the visible movies to MovieList. AddMovieForm and FilterBar call their callbacks to send changes back up to App. App movies · filter Header AddMovieForm FilterBar MovieList props down dashed: onAdd / onChange calls back up
State lives in App; props flow down, events flow up

Data down, events up

Notice the shape. Data flows down the tree as props: movies, filter, visible. Changes flow up as function calls: the form calls onAdd, the filter bar calls onChange. Only App ever calls setMovies or setFilter.

That’s the payoff. If the list ever shows the wrong movies, there’s exactly one place that decides what’s in it. You follow the props up to their owner and read the code that changes them. Compare that with an app where any component can poke any other: a bug could come from anywhere.

Look at what’s not state, too. visible is calculated from movies and filter on every render, and Header derives its count from movies. Lifting state doesn’t mean lifting everything; it means one owner for each piece of real state, and everything else derived from it.

Signs you need to lift

You rarely plan every piece of state perfectly up front. You discover the need to lift while building, and the signs are consistent:

  • Two components show values that should match but sometimes don’t.
  • You’re about to write an effect whose only job is to copy one component’s state into another’s.
  • A child needs to change something a sibling displays.

Each of these means the same data has two owners, or the owner is too low in the tree. Lifting is a small, mechanical refactor: cut the useState line, paste it into the parent, and replace the old state in the child with props.

As low as possible, as high as necessary

Lifting has a cost: the owner re-renders when the state changes, along with its children, and props have to travel further. So don’t lift everything to App by reflex.

The text being typed into the title field is a good example. Only AddMovieForm cares about it while you type. App only needs the finished title, which it gets from onAdd. So the draft stays in the form. If later some other component needed to preview the draft, you’d lift it then.

When state needs to be read by components far apart in the tree, passing it through every level becomes tedious. You saw one fix in the composition lesson (pass elements down), and React’s context is the other. For the Watchlist, a few props are all you need.

Your Watchlist now has one source of truth and three components that read and change it. Next, you’ll sync that state with something outside React, the browser tab’s title and localStorage, using effects.

Key takeaways

  • When two components need the same changing data, move the state to their closest common parent and pass it down as props.
  • Children change shared state by calling handler props such as onAdd or onFilterChange; data flows down and events flow up.
  • Keep each piece of state as low in the tree as possible but as high as necessary.
  • Don’t copy a prop into state with useState(prop); the copy ignores later changes. Use the prop directly or derive from it.

Further reading

Check your understanding

4 questions

Question 1 of 4

FilterBar keeps const [filter, setFilter] = useState('all'). MovieList, its sibling, needs to show only the movies matching the filter. What’s the fix?

Question 2 of 4

Which piece of Watchlist state should stay inside AddMovieForm rather than being lifted to App?

Question 3 of 4

MovieTitle shows a title it receives as a prop. When the parent renames the movie, the child keeps showing the old title. Why?

JSX
function MovieTitle({ title }) {
  const [text] = useState(title);
  return <h2>{text}</h2>;
}
Question 4 of 4

In the Watchlist, data flows down as props and changes flow up through callbacks. What’s the main benefit of this one-way flow?

Practice

Code playground

Lift the filter

The filter buttons highlight correctly, but the list never changes, because FilterBar keeps the filter to itself.

Lift the filter state into App. Pass the current value and a way to change it down to FilterBar (it should have no state of its own), and give MovieList only the movies that match:

  • All: every movie
  • To watch: watched is false
  • Watched: watched is true

Keep aria-pressed on the buttons working.

import { useState } from 'react';

const MOVIES = [
  { id: 'a1', title: 'Arrival', watched: true },
  { id: 'p2', title: 'Past Lives', watched: false },
  { id: 'd3', title: 'Dune: Part Two', watched: false },
];

function MovieList({ movies }) {
  return (
    <ul>
      {movies.map((m) => <li key={m.id}>{m.title}</li>)}
    </ul>
  );
}

function FilterBar() {
  const [filter, setFilter] = useState('all');
  return (
    <div>
      <button aria-pressed={filter === 'all'} onClick={() => setFilter('all')}>All</button>
      <button aria-pressed={filter === 'to-watch'} onClick={() => setFilter('to-watch')}>To watch</button>
      <button aria-pressed={filter === 'watched'} onClick={() => setFilter('watched')}>Watched</button>
    </div>
  );
}

export default function App() {
  // MovieList should only show movies that match the selected filter.
  return (
    <main>
      <FilterBar />
      <MovieList movies={MOVIES} />
    </main>
  );
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • Starts on All, showing all three movies
  • To watch shows Past Lives and Dune: Part Two, and is pressed
  • Watched shows only Arrival, and is pressed
  • All shows every movie again