Lifting state up
The core pattern for two sibling components sharing the same data. When to lift, when not to, and the natural bridge into Chapter 12 useContext.
Chapter 10 covered useEffect, the tool for interacting with the outside world. The components we have seen so far all carried their own state. In real apps, though, several components often need to share the same data. This chapter covers lifting state up, the core pattern for those cases.
The model in this chapter flows naturally into Chapter 12 (useContext). At the end of this chapter we mark the boundary between problems that lifting solves cleanly and the cases where the depth gets too large and a different tool is needed.
Data flows in one direction #
This is a principle we touched briefly in Chapter 4. React’s data flows from parent to child, one direction only. A child cannot mutate a parent’s data directly, and siblings cannot pass data to each other directly either.
So what do you do when two sibling components need to share the same data? The answer is simple.
Move the state up to a common parent.
That is “lifting state up”. You put the state both children need into their nearest common parent, and have that parent pass it down to the children as props.
The problem — a currency converter #
Suppose we are building two components that convert between KRW and USD. As a first try, let’s give each one its own input value.
import { useState } from 'react';
function CurrencyInput({ label }) {
const [amount, setAmount] = useState('');
return (
<div>
<label>{label}: </label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</div>
);
}
export default CurrencyInput;import CurrencyInput from './CurrencyInput';
function App() {
return (
<div>
<CurrencyInput label="KRW" />
<CurrencyInput label="USD" />
</div>
);
}Both inputs work fine, but they are unrelated to each other. Typing 1000 in the KRW box does not auto-fill the converted USD value in the other box. The two components each have their own separate state.
Applying lifting state up #
We fix this by lifting the two inputs’ state up into the common parent, App.
import { useState } from 'react';
import CurrencyInput from './CurrencyInput';
const RATE = 1300; // 1 USD = 1300 KRW
function App() {
const [krw, setKrw] = useState('');
const usd = krw === '' ? '' : (Number(krw) / RATE).toFixed(2);
function handleKrwChange(value) {
setKrw(value);
}
function handleUsdChange(value) {
setKrw(value === '' ? '' : (Number(value) * RATE).toString());
}
return (
<div>
<CurrencyInput label="KRW" value={krw} onChange={handleKrwChange} />
<CurrencyInput label="USD" value={usd} onChange={handleUsdChange} />
</div>
);
}
export default App;function CurrencyInput({ label, value, onChange }) {
return (
<div>
<label>{label}: </label>
<input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
export default CurrencyInput;The key changes:
CurrencyInputno longer carries its own state (it receives a controlled component shape via props)- The single source of truth, the
krwstate, lives only inApp - USD is computed from
krwfor display (no separate state) - No matter which input the user types in, both updates land on the same
krwstate - When the state changes, both children re-render and the screen stays in sync
Now typing a value into one box auto-fills the converted value in the other. The two components look like they are talking to each other, but they are actually interacting through their common parent.
How a child sends data up to its parent #
In the example above, how did the child (CurrencyInput) hand the input it received up to its parent?
<CurrencyInput onChange={handleKrwChange} />The parent passes a handler function down as a prop, and the child calls that function with the value as an argument. The child does not touch the parent’s state directly; it speaks through a “tell me when this happens” channel — a callback function — that the parent has set up.
This pattern is an extension of what we saw briefly in Chapter 6 (event handling). Back then we were merely signaling a click; this time we are also passing the new value along.
How high should you lift? #
The answer is “up to the nearest common ancestor of every component that needs the data”.
Imagine the following component tree.
App
├── Header
└── Main
├── Sidebar
└── Content
├── Article
└── CommentsIf Article and Comments share the same data, the state can live in Content. There is no need to lift it all the way up to App.
If Header and Article share the data, then it does need to go up to App — that is their nearest common ancestor.
Lifting too high gets you components in the middle that don’t use the prop but receive it just to pass it down. That is called prop drilling, and we tackle it with Context in Chapter 12. For now, remember the rule of “only as high as the common parent”.
Single Source of Truth #
Another important takeaway from lifting state up is the principle that the same piece of information should not be stored in more than one place. Look at the currency example again.
const [krw, setKrw] = useState('');
const usd = krw === '' ? '' : (Number(krw) / RATE).toFixed(2);We derive USD from krw rather than keeping it as a separate state. If USD were its own state, keeping the two values in sync would be tricky. You would need an effect that updates one when the other changes, and sync bugs would creep in.
Don’t make state out of values you can compute. Real state is only the “source information” — what the user types in or what we fetch from outside. Derived values should just be computed as variables.
This principle is the same idea as “the cases where you should not use useEffect” from Chapter 10. Plain computation belongs in a variable; only real outside-world sync needs useEffect.
A larger example — counter + display components #
One more example. Suppose we have Controls containing +1 / -1 buttons, and Display showing the count plus whether it is even or odd.
function Controls({ onIncrement, onDecrement, onReset }) {
return (
<div>
<button onClick={onIncrement}>+1</button>
<button onClick={onDecrement}>-1</button>
<button onClick={onReset}>Reset</button>
</div>
);
}
export default Controls;function Display({ count }) {
return (
<div>
<h2>{count}</h2>
<p>{count % 2 === 0 ? 'Even' : 'Odd'}</p>
</div>
);
}
export default Display;The two components share the same count. Controls changes the count, and Display shows it. We put the state in the common parent App.
import { useState } from 'react';
import Controls from './Controls';
import Display from './Display';
function App() {
const [count, setCount] = useState(0);
return (
<div style={{ padding: '16px' }}>
<Display count={count} />
<Controls
onIncrement={() => setCount(prev => prev + 1)}
onDecrement={() => setCount(prev => prev - 1)}
onReset={() => setCount(0)}
/>
</div>
);
}
export default App;Controls and Display don’t even know the other exists. Each one talks only to its parent, and the parent brokers between them. Each child component becomes simpler and more reusable — that is the big benefit of lifting state up.
You could pull Display out and use it on another screen, or use Controls as the controller for another counter. If the two components were wired directly to each other, that kind of reuse would be impossible.
Where lifting hits its limit — the bridge to the next chapter #
Lifting state up is a powerful pattern, but it has one weakness. When the components that need the data are far apart in the tree, middle components end up shuttling props they don’t care about. As the tree grows deeper, this prop drilling gets heavier.
<App user={user}>
<Layout user={user}>
<Sidebar user={user}>
<ProfileMenu user={user}>
<UserAvatar user={user} /> {/* The component that actually needs user */}
</ProfileMenu>
</Sidebar>
</Layout>
</App>Layout, Sidebar, and ProfileMenu don’t care about user. They receive the prop only to pass it further down. The next chapter, Chapter 12 useContext, introduces Context as the tool for this problem.
And when the shared state is not just a tree-wide value but a specific domain (global user info, cart, notifications) that gets read and written in many places, even Context starts to feel insufficient. At that point an external state library like Zustand / Jotai / Redux Toolkit is the better fit. Part 5 of this book does not cover those external tools directly, but Chapter 12 marks the boundary between Context and them.
Try it yourself #
Let’s refactor the sign-up form from Chapter 9, breaking it into child components while keeping the state in the parent (SignupForm).
Create src/TextField.jsx (a reusable input field).
function TextField({ label, name, value, onChange, type = 'text' }) {
return (
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'inline-block', width: '80px' }}>{label}: </label>
<input
type={type}
name={name}
value={value}
onChange={(e) => onChange(name, e.target.value)}
/>
</div>
);
}
export default TextField;src/SignupForm.jsx:
import { useState } from 'react';
import TextField from './TextField';
function SignupForm() {
const [form, setForm] = useState({ name: '', email: '', password: '' });
function handleFieldChange(name, value) {
setForm(prev => ({ ...prev, [name]: value }));
}
function handleSubmit(e) {
e.preventDefault();
console.log('Signup info:', form);
}
const isValid = form.name && form.email && form.password.length >= 8;
return (
<form onSubmit={handleSubmit} style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>Sign up</h2>
<TextField label="Name" name="name" value={form.name} onChange={handleFieldChange} />
<TextField label="Email" name="email" type="email" value={form.email} onChange={handleFieldChange} />
<TextField label="Password" name="password" type="password" value={form.password} onChange={handleFieldChange} />
<button type="submit" disabled={!isValid}>Sign up</button>
{!isValid && (
<p style={{ color: 'red', fontSize: '12px' }}>Fill every field and use a password of 8 characters or more.</p>
)}
</form>
);
}
export default SignupForm;What happens here:
TextFieldcarries no state. It receives only a value (value) and a change channel (onChange) as props- The real state (
form) lives in the parentSignupForm - When the child receives input, it forwards it to the parent via
onChange(name, value) - The parent updates the matching field on the object
- When the state changes, the parent re-renders and the children receive new props
Because the child component (TextField) is generalized regardless of input type, adding a new field is one line: <TextField label="..." name="..." />.
Exercises #
- Add one more currency to the converter above. KRW / USD / JPY — three input boxes that stay in sync at once. Keep the single source of truth as just the KRW state, and compute USD and JPY from it. Assume 1 USD = 1300 KRW and 1 JPY = 9 KRW.
- Think briefly about whether
DisplayandControlscould share the same count without a parent. The answer is “no, not without lifting up”. Explain in a short paragraph why, citing React’s one-way data flow principle. Notice that Context in Chapter 12 is still, ultimately, a way of flowing data within a tree. - Refactor the sign-up form from Chapter 9 into the
TextField+SignupFormpattern above. Also build child components for radio / checkbox / textarea inputs. Notice which components have been demoted to “middlemen” that only pass props through — that is where prop drilling begins. Feeling that yourself makes Chapter 12 read naturally.
In one line: data flows parent → child in one direction. When two components need to share the same state, lift it up to the common parent. The channel for a child to notify its parent is a callback function prop (
onChange,onClick, etc.). Don’t make state out of values you can compute — leave them as variables (Single Source of Truth). Once the child becomes controlled, it turns into a smaller, more reusable unit.
Next chapter #
The next chapter, Chapter 12 useContext, covers the Context API, which solves the prop drilling problem. It is a model where you set up a channel anywhere in the tree and any descendant can read from it directly. We will also mark the cost of Context (broad re-renders when its value changes) and the boundary toward external state libraries.