ReactLifting State Up

Lifting State Up

When two sibling components need to reflect the same changing data, the answer is to move that state up to their nearest common ancestor and pass it down as props. This is called lifting state up, and it is one of the most important patterns in React.

The principle behind it is single source of truth: there should be exactly one place in the component tree that owns any given piece of state. When multiple components need to agree on a value, one of them (or a shared parent) must own it — not each component independently.

The Problem: Sibling State Is Isolated

Imagine a temperature converter with two inputs — one in Celsius and one in Fahrenheit. When the user types in one field, the other should update automatically. Here is the broken first attempt where each input manages its own state:

JSX
// ✗ BROKEN — each input has its own state; they cannot stay in sync
function CelsiusInput() {
  const [celsius, setCelsius] = useState('')
  return (
    <label>
      Celsius:
      <input value={celsius} onChange={e => setCelsius(e.target.value)} />
    </label>
  )
}

function FahrenheitInput() {
  const [fahrenheit, setFahrenheit] = useState('')
  return (
    <label>
      Fahrenheit:
      <input value={fahrenheit} onChange={e => setFahrenheit(e.target.value)} />
    </label>
  )
}

// There is no way for CelsiusInput to update FahrenheitInput's state.
// They live in separate, isolated state bubbles.
function TemperatureConverter() {
  return (
    <>
      <CelsiusInput />
      <FahrenheitInput />
    </>
  )
}

Each input keeps its own value, but they know nothing about each other. Typing 100 in the Celsius field cannot magically update the Fahrenheit field, because sibling components cannot directly write to each other's state.

The Fix: Lift State to the Parent

The solution is to remove the local state from both children and instead hold the single source of truth in their shared parent. The parent passes the current value and an onChange handler down as props. The children become controlled components — they display what they receive and report user input upward:

JSX
import { useState } from 'react'

// Helper conversions
function toCelsius(f) {
  return ((f - 32) * 5) / 9
}
function toFahrenheit(c) {
  return (c * 9) / 5 + 32
}
function round(value) {
  return Math.round(value * 1000) / 1000
}

// ✓ Each input is now a pure controlled component
function TemperatureInput({ scale, value, onValueChange }) {
  return (
    <label>
      {scale === 'c' ? 'Celsius' : 'Fahrenheit'}:
      <input
        value={value}
        onChange={e => onValueChange(e.target.value)}
      />
    </label>
  )
}

// ✓ Parent owns the single source of truth
function TemperatureConverter() {
  const [celsius, setCelsius] = useState('')

  // Fahrenheit is DERIVED — not stored separately
  const fahrenheit = celsius !== '' ? round(toFahrenheit(Number(celsius))) : ''

  function handleCelsiusChange(value) {
    setCelsius(value)
  }

  function handleFahrenheitChange(value) {
    // Convert back to Celsius and store only that
    setCelsius(value !== '' ? String(round(toCelsius(Number(value)))) : '')
  }

  return (
    <div>
      <TemperatureInput
        scale="c"
        value={celsius}
        onValueChange={handleCelsiusChange}
      />
      <TemperatureInput
        scale="f"
        value={fahrenheit}
        onValueChange={handleFahrenheitChange}
      />
      {celsius !== '' && (
        <p>
          {celsius}°C = {fahrenheit}°F
        </p>
      )}
    </div>
  )
}

Key insight: we only store one value — celsius — in state. Fahrenheit is computed during render. There is no risk of the two values ever diverging because there is only one value to keep track of.

How Data Flows in the Fixed Version
  • TemperatureConverter owns celsius in state — this is the single source of truth.

  • It computes fahrenheit inline during render (derived value, not separate state).

  • Both TemperatureInput children receive their display value and a change handler as props.

  • When the user types in the Fahrenheit input, handleFahrenheitChange converts the value back to Celsius and calls setCelsius, which re-renders the whole tree with the new derived Fahrenheit.

  • The Celsius input re-renders with the stored value unchanged — the two are always in sync.

Note
Notice that neither child component calls useState. They are now fully controlled — "dumb" display-and-report components. All the logic lives in the parent. This is intentional: lifting state centralises the logic, making it easier to understand and debug.
The Single Source of Truth Principle

Whenever two pieces of data should always agree, they are really just one piece of data seen from two angles. The rule is: store data once, derive everywhere else. Duplication is the enemy:

JSX
// ✗ Two sources of truth — prone to desync
const [celsius, setCelsius]       = useState(0)
const [fahrenheit, setFahrenheit] = useState(32)   // duplicated!

// ✓ One source of truth — Fahrenheit is always correct
const [celsius, setCelsius] = useState(0)
const fahrenheit = (celsius * 9) / 5 + 32           // derived during render
When to Lift State

Situation

Action

Two siblings need the same value

Lift state to their shared parent

A parent needs to know what a child is showing

Lift state to the parent

A deeply nested component needs state from far above

Consider Context API instead of lifting

State is only used in one component

Keep it local — do not lift unnecessarily

The Downside: Prop Drilling

Lifting state works well for one or two levels, but when state is lifted high and must be passed down through many intermediate components that do not use it themselves, you encounter prop drilling:

JSX
// Prop drilling across 4 levels — cumbersome
function App() {
  const [theme, setTheme] = useState('light')
  return <Layout theme={theme} setTheme={setTheme} />
}

function Layout({ theme, setTheme }) {
  // Layout doesn't USE theme, just passes it down
  return <Sidebar theme={theme} setTheme={setTheme} />
}

function Sidebar({ theme, setTheme }) {
  // Sidebar doesn't USE theme either
  return <ThemeToggle theme={theme} setTheme={setTheme} />
}

function ThemeToggle({ theme, setTheme }) {
  // ThemeToggle is the only one that actually needs theme
  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
      Current: {theme}
    </button>
  )
}
Warning
Prop drilling is not always bad — for two or three levels it is fine. It only becomes a problem when intermediate components are burdened with props they don't use, making them harder to refactor. That is when the Context API becomes the right tool.
When to Stop Lifting and Use Context

Use the following heuristic to decide between lifting state and reaching for Context:

  • 1–2 levels of prop passing — lift state. It is explicit and easy to trace.

  • 3+ levels or many unrelated components — consider useContext. The Context API was designed exactly for this case.

  • Global, low-frequency data (current user, theme, locale, feature flags) — Context is the natural fit.

  • High-frequency updates (e.g. a value that changes on every keystroke) — be careful with Context because every consumer re-renders. Lifted state only re-renders the sub-tree that actually uses it.

Tip
A good rule of thumb: start by lifting state. When prop drilling becomes painful — when you catch yourself adding a prop to a component just to pass it to the next level — that is the signal to switch to Context.
Quick Reference
  • Sibling components cannot share state directly — they must communicate via a shared parent.

  • Lift state to the nearest common ancestor of all components that need it.

  • Store the minimal state, derive everything else. One source of truth eliminates sync bugs.

  • Pass the value down as a prop and the setter as an onChange-style callback.

  • When drilling becomes painful across many levels, switch to the Context API.