React Basics #11: Lifting state up
Last time we covered useEffect, the tool for interacting with the outside world. So far every component we’ve worked with has had its own state. But in real apps, multiple components often need to share the same data. This time we’ll learn the core pattern for those cases: lifting state up.
Data flows in one direction #
This is a principle we touched on briefly in #4. In React, data flows one way, from parent to child. Children can’t change parent data directly, and siblings can’t pass data directly to each other.
So how do you handle the case where two sibling components need to share data? The answer is surprisingly simple.
Move the state up to the common parent.
That’s lifting state up. Put the state the two children need in their nearest common parent, and have that parent pass it down to the children via props.
A problem — currency converter #
Suppose you’re building two components that convert between Korean won and US dollars. First, let’s give each their own 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 (Korean won)" />
<CurrencyInput label="USD (US dollar)" />
</div>
);
}The two inputs work, but they have nothing to do with each other. Typing 1000 won in one doesn’t auto-fill the converted dollar amount in the other. The two components have separate state.
Applying lifting state up #
Solve the problem by lifting the state of the two inputs up to their 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 (Korean won)" value={krw} onChange={handleKrwChange} />
<CurrencyInput label="USD (US dollar)" 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;Key changes:
CurrencyInputno longer has its own state (it’s now a controlled component receiving props)krwis the single source of truth; only one exists, inApp- USD is computed from
krwfor display (no separate state) - Whichever input the user types into, the same
krwstate ends up updated - When state changes, both children re-render and the screen stays in sync
Now typing in one input automatically fills the other with the converted value. The two components look like they’re communicating, but really they’re interacting through the common parent.
How a child sends data up to its parent #
In the example above, how did the child (CurrencyInput) tell its parent about its input?
<CurrencyInput onChange={handleKrwChange} />The parent passes a handler function down via props, and the child calls that function with the value. The child doesn’t touch the parent’s state directly — it goes through the channel (a callback function) the parent has set up that says, “when this happens, let me know.”
This pattern is an extension of what you saw briefly in #6. There you just signaled a click; this time you also pass the changed value.
How far up 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 need to share data, the state can live in Content. There’s no need to lift it up to App.
If Header and Article share, you need to lift it up to App, since that’s their nearest common ancestor.
Lifting too high makes components that don’t even need that state receive props just to pass them down. This is called prop drilling, and we’ll cover the tool that solves it — Context — in the next article (#12). For now, just remember the rule: lift only up to the common parent.
Single source of truth #
Another important consequence of lifting state up is the principle of not storing the same information in multiple places. Look back at the currency example.
const [krw, setKrw] = useState('');
const usd = krw === '' ? '' : (Number(krw) / RATE).toFixed(2);USD isn’t a separate state — we derive it from krw. If we’d made USD its own state, keeping the two values aligned would have been tricky. We’d need an effect to update one when the other changes, and sync bugs are easy to introduce.
Don’t make state out of values you can compute. Real state is information that the user types in or comes from outside; values derived from those should just be computed as variables.
This is the same idea as “don’t overuse useEffect” from the useEffect article. Simple computations: variables. Real outside-world sync: useEffect.
A bigger example — counter + display components #
Let’s look at one more. Suppose there’s a Controls with +1/-1 buttons and a Display showing the count and whether it’s 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;Both components share the same count. Controls changes the count; Display shows it. Put the state in App, the common parent.
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 about each other. Each talks only to the parent, and the parent brokers between them. Each child component becomes simpler and more reusable — that’s a big benefit of lifting state up.
You could pull Display out and use it in another screen, or use Controls as the controller for a different counter. If the two components were directly wired together, none of that reuse would be possible.
Try it yourself #
Let’s refactor the signup form from #9 by breaking it into child components and putting 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' }}>All fields are required and the password must be at least 8 characters.</p>
)}
</form>
);
}
export default SignupForm;What’s happening here:
TextFieldhas no state of its own. It only receives props for the value to display and a callback for changes- The real state (
form) lives in the parent,SignupForm - When the child receives input, it calls
onChange(name, value)to inform the parent - The parent updates the relevant field of the object
- When state changes, the parent re-renders and the children receive new props
The child component (TextField) is generalized regardless of input type, so to add a new field you just write one more line (<TextField label="..." name="..." />).
Wrapping up #
In this article we covered the core pattern for sharing data between sibling components: lifting state up. To summarize:
- Data flows one way, parent → child
- When two components need to share state, lift it up to the common parent
- The channel for a child to inform the parent is a callback function prop (
onChange,onClick, etc.) - Don’t make state for values you can compute — use a variable (single source of truth)
- Once children become controlled, they end up smaller and more reusable
Lifting state up is a powerful pattern, but as the distance between the components that need the data grows, middle components end up just forwarding props they don’t care about. The deeper the component tree, the more cumbersome this prop drilling becomes. In the next article, “React Basics #12: useContext,” we’ll learn the tool that solves this problem — Context.