Lifting State Up in React
When two components need to share state, you move it to their closest common ancestor. Here's what that looks like in practice and why it works.
There's a point every React developer hits where two separate components both need to know about the same piece of data. My first instinct was usually wrong: I'd try to sync state between siblings, or reach for a global state library before I actually needed one. The real answer is simpler — move the state up to the nearest component that contains both of them.
This is called lifting state up, and it's one of those ideas that sounds obvious once you understand it but is surprisingly easy to miss when you're building something.
The Problem It Solves
Say you have a temperature converter with two inputs: one for Celsius and one for Fahrenheit. When the user types in one field, the other should update automatically.
If each input manages its own state, they don't know about each other:
function CelsiusInput() {
const [celsius, setCelsius] = useState('');
return (
<input value={celsius} onChange={e => setCelsius(e.target.value)} />
);
}
function FahrenheitInput() {
const [fahrenheit, setFahrenheit] = useState('');
return (
<input value={fahrenheit} onChange={e => setFahrenheit(e.target.value)} />
);
}
These two components are islands. Typing in one has no effect on the other. To connect them, you need a component above both of them to own the shared value.
Moving State to the Parent
The fix is to remove local state from both inputs and instead accept the current value and a change handler as props. The parent holds the actual state:
interface TemperatureInputProps {
value: string;
onChange: (value: string) => void;
}
function TemperatureInput({ value, onChange }: TemperatureInputProps) {
return (
<input value={value} onChange={e => onChange(e.target.value)} />
);
}
function TemperatureConverter() {
const [celsius, setCelsius] = useState('');
const fahrenheit = celsius !== ''
? String((parseFloat(celsius) * 9) / 5 + 32)
: '';
return (
<div>
<TemperatureInput value={celsius} onChange={setCelsius} />
<TemperatureInput
value={fahrenheit}
onChange={f => setCelsius(String(((parseFloat(f) - 32) * 5) / 9))}
/>
</div>
);
}
Now TemperatureConverter is the single source of truth. Both inputs are fully controlled, and any change to either one flows through the parent. The conversion logic lives in one place, not scattered across two components.
What "Controlled" Actually Means
When a component receives its value and change handler from outside rather than managing its own state, it's a controlled component. The value it displays is always what the parent says it is.
This is the pattern behind form libraries, table components with external sorting, any UI where two parts of the screen need to stay in sync. The component itself becomes a view into some state held elsewhere.
A component that manages its own state internally is uncontrolled. Both have their place, but when coordination is needed, you need the controlled version.
How Far Up Do You Lift?
Lift only as far as necessary. The rule is: find the closest common ancestor of all the components that need the state, then put it there.
If you have a sidebar and a main panel that both need to know which item is selected, and they're both children of a Layout component, then Layout owns that state. You don't need global state for this.
App
└── Layout ← selectedId lives here
├── Sidebar ← receives selectedId, onSelect
└── MainPanel ← receives selectedId
People sometimes skip straight to Redux or Zustand because lifting state feels tedious. Sometimes a global store is the right answer, but not always. Start close to the components that need it, and only push state further up the tree when something genuinely forces your hand.
Passing Down Through Multiple Levels
One real limitation: if the shared state ends up three or four levels above the components that actually use it, you end up passing props through components that don't need them at all. This is prop drilling.
Layout ← selectedId
└── PageBody ← passes it down (doesn't use it)
└── List ← passes it down (doesn't use it)
└── ListItem ← finally uses it
When you see this pattern, that's the signal to reach for the Context API, which lets you make state available anywhere in the subtree without threading it through every intermediate component.
Lifting state up and Context are a natural pair. Lift the state, and if the prop-passing becomes unwieldy, wrap the relevant subtree in a context provider.
What Changes When You Lift State
The components you lift out of become simpler. They have no internal memory, no side effects from their own state changes. They're easier to test because you can render them with any value and any handler you want, and see exactly what they render.
The parent becomes slightly more complex, but it's a good kind of complexity. It has a clear, explicit picture of what's happening in its subtree. When you look at the parent, you can see the state and understand the relationships between its children without opening each child file to find out what they're managing internally.
The mental shift that made this click for me: components don't own state, they use it. State lives wherever it needs to live to be shared by the right things. That's all lifting state up is.
Newsletter
A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.