Lifting State Up
Lifting state up is the standard React pattern for sharing data between two or more sibling components: instead of each component keeping its own private copy of a value, you move (“lift”) that state to their closest common parent, which then passes it back down as props. This solves a fundamental limitation of React's one-way data flow — sibling components cannot talk to each other directly, so the only way for them to stay in sync is through a shared ancestor. Learning when and how to lift state is one of the most important React skills, because it is the difference between components that mysteriously fall out of sync and an app where data flows predictably from a single source of truth.
Overview / How It Works
React data flows in one direction: from parent to child, via props. A child component has no direct way to read or change a sibling's state, and it cannot reach "up" into its parent's state either — unless the parent explicitly hands it a way to do so. That "way to do so" is a callback function passed down as a prop. This gives you the two halves of the pattern: data flows down as props, and change requests flow up as function calls.
When you notice that two components need to reflect or modify the same piece of information, the fix is never to give both components their own useState call for that value. Instead, find their closest common ancestor in the component tree, put the state there with useState, and pass the current value down to both children as a prop, along with a setter (or a wrapper function) they can call to request a change. The parent becomes the single source of truth: it owns the data, and every other component just reads a snapshot of it through props.
Under the hood, this works because of how React schedules updates. Calling a state setter (like setCelsius) does not mutate anything in place — it tells React "this component's state has changed, please re-render it." React then re-runs the parent function component, which produces new JSX using the new state value. Because both sibling components receive that same state value as a prop, when the parent re-renders, both children receive updated props and re-render along with it. That is exactly why lifting the state fixes the "out of sync" problem: both siblings are now reading from the exact same value on every render, rather than from two independent copies that can drift apart.
This is also why hooks like useState must always run in the same order on every render: React matches each hook call to its stored state by the order it was called in, not by name. If a component conditionally skipped a useState call, React would attach the wrong stored value to the wrong hook on the next render. That is the underlying reason hooks can only be called at the top level of a component, never inside conditionals or loops.
Syntax
There is no special API for lifting state — it is a pattern built entirely from useState and props. The general shape looks like this:
function Parent() {
const [value, setValue] = useState(initialValue);
return (
<>
<ChildA value={value} onChange={setValue} />
<ChildB value={value} />
</>
);
}
| Part | Meaning |
|---|---|
useState(initialValue) |
The state lives in the parent, not in either child |
value={value} |
The data flows down to each child that needs to display it |
onChange={setValue} |
A callback flows down so a child can request a change; calling it re-renders the parent |
ChildB without a callback |
A child that only reads the value does not need to receive the setter |
Examples
Example 1: Two linked inputs (simple)
import { useState } from "react";
function CelsiusInput({ celsius, onChangeCelsius }) {
return (
<label>
Celsius:
<input
type="number"
value={celsius}
onChange={(e) => onChangeCelsius(Number(e.target.value))}
/>
</label>
);
}
function FahrenheitInput({ celsius, onChangeCelsius }) {
const fahrenheit = (celsius * 9) / 5 + 32;
function handleChange(e) {
const nextFahrenheit = Number(e.target.value);
onChangeCelsius(((nextFahrenheit - 32) * 5) / 9);
}
return (
<label>
Fahrenheit:
<input type="number" value={fahrenheit} onChange={handleChange} />
</label>
);
}
function TemperatureConverter() {
const [celsius, setCelsius] = useState(20);
return (
<div>
<CelsiusInput celsius={celsius} onChangeCelsius={setCelsius} />
<FahrenheitInput celsius={celsius} onChangeCelsius={setCelsius} />
<p>The water would be {celsius >= 100 ? "boiling" : "not boiling"}.</p>
</div>
);
}
export default TemperatureConverter;
Renders: two labeled number inputs (Celsius showing 20, Fahrenheit showing 68) and a sentence reading "The water would be not boiling." Typing 100 into the Fahrenheit field updates the Celsius field to about 37.8 and changes the sentence to "The water would be boiling." Neither CelsiusInput nor FahrenheitInput owns any state of its own — both simply display and edit the single celsius value that lives in TemperatureConverter. That is why editing either one instantly updates the other: they are two views of one source of truth, not two independent copies.
Example 2: Selection state (accordion)
import { useState } from "react";
const faqs = [
{ id: "faq1", question: "What is React?", answer: "A JavaScript library for building UIs." },
{ id: "faq2", question: "What is a hook?", answer: "A function that lets you use React features." },
{ id: "faq3", question: "What is JSX?", answer: "A syntax extension that looks like HTML in JS." },
];
function FaqItem({ question, answer, isOpen, onToggle }) {
return (
<div>
<button onClick={onToggle}>{question}</button>
{isOpen && <p>{answer}</p>}
</div>
);
}
function FaqList() {
const [openId, setOpenId] = useState(null);
return (
<div>
{faqs.map((faq) => (
<FaqItem
key={faq.id}
question={faq.question}
answer={faq.answer}
isOpen={openId === faq.id}
onToggle={() => setOpenId(openId === faq.id ? null : faq.id)}
/>
))}
</div>
);
}
export default FaqList;
Renders: three buttons, one per question. Clicking a question reveals its answer paragraph directly beneath it; clicking a different question closes the previous answer and opens the new one, because only one openId exists in the parent FaqList. If each FaqItem tracked its own "am I open" boolean, several answers could be open at once with no way to enforce "only one at a time" — lifting the single openId value up is what makes that rule possible.
Example 3: A realistic shopping list (add and remove)
import { useState } from "react";
function AddItemForm({ onAdd }) {
const [text, setText] = useState("");
function handleSubmit(e) {
e.preventDefault();
if (text.trim() === "") return;
onAdd(text.trim());
setText("");
}
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add an item"
/>
<button type="submit">Add</button>
</form>
);
}
function ItemList({ items, onRemove }) {
if (items.length === 0) {
return <p>Your list is empty.</p>;
}
return (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.text}
<button onClick={() => onRemove(item.id)}>Remove</button>
</li>
))}
</ul>
);
}
function ShoppingApp() {
const [items, setItems] = useState([]);
function handleAdd(text) {
setItems([...items, { id: Date.now(), text }]);
}
function handleRemove(id) {
setItems(items.filter((item) => item.id !== id));
}
return (
<div>
<h2>Shopping List</h2>
<AddItemForm onAdd={handleAdd} />
<ItemList items={items} onRemove={handleRemove} />
<p>{items.length} item(s)</p>
</div>
);
}
export default ShoppingApp;
Renders: a heading, a text input with an Add button, and initially "Your list is empty." with "0 item(s)". Typing "Milk" and clicking Add shows a list item reading "Milk" with a Remove button, and the count updates to "1 item(s)". Clicking Remove deletes that item and the list returns to its empty state. Here AddItemForm owns its own local text state (the draft text being typed, which nobody else needs), but the items array is lifted to ShoppingApp because both AddItemForm (indirectly, via onAdd) and ItemList need to affect or display the same list.
How It Works Step by Step
On mount: the parent component calls useState, which registers its initial value with React and returns it. The parent renders its JSX, passing that value down to each child as a prop. Each child renders using the prop it received.
On a user interaction: a child's event handler runs (for example, onChange on an input). Instead of calling its own useState setter — it does not own this state — it calls the callback prop the parent gave it, passing along the new value.
On the state update: that callback is actually the parent's setter function (or a small wrapper around it). Calling it schedules a re-render of the parent. React does not mutate the old state in place; it stores the new value and marks the parent as needing to re-render. If several state updates happen inside the same event handler, React batches them into a single re-render rather than one per call.
On re-render: React re-runs the parent function component from top to bottom with the new state value, producing a new tree of React elements (a lightweight description of the UI, sometimes called the virtual DOM). React then reconciles this new tree against the previous one, diffing them to figure out exactly what changed. Every child that receives a new or different prop value is re-rendered as part of this pass — which is precisely why both siblings update together: they are both downstream of the same changed state.
On commit: once reconciliation is done, React applies only the minimal necessary changes to the real DOM (updating an input's displayed value, adding or removing a list item, and so on), rather than re-building the page from scratch.
Common Mistakes
Mistake 1: Duplicating state instead of lifting it
function CelsiusInput() {
const [celsius, setCelsius] = useState(20); // its own private copy
// ...
}
function FahrenheitInput() {
const [celsius, setCelsius] = useState(20); // a second, separate copy
// ...
}
This looks reasonable at first glance, but each component now owns an independent copy of the same conceptual value. Editing one input changes only its own local state; the other component never finds out, so the two inputs immediately drift out of sync. The fix is to remove useState from both children entirely and instead have a shared parent hold the single celsius value, passing it (and a way to change it) down to both, exactly as in Example 1.
Mistake 2: Mutating the lifted state instead of replacing it
function handleAdd(text) {
items.push({ id: Date.now(), text }); // mutates the existing array in place
setItems(items); // same array reference — React may not detect a change
}
React decides whether to re-render by comparing the new state to the old state, and for objects and arrays that comparison is by reference. Pushing onto the existing array and then handing that same array reference back to setItems means the reference never changes, so React can skip re-rendering, and any child relying on React.memo or a memoized value will silently fail to update. The fix is to always build a new array or object: setItems([...items, { id: Date.now(), text }]), as shown in Example 3.
Mistake 3: Lifting state higher than necessary
It is tempting to lift every piece of state all the way up to a top-level App component "just in case" something down the tree eventually needs it. This forces that value to be threaded through props across every intermediate component, even ones that never use it — a problem known as prop drilling. It also causes those intermediate components (and everything below the point where the state lives) to re-render whenever the value changes, even if most of them never display it. Lift state only as far as the closest common ancestor of the components that genuinely need to share it, and reach for useContext only when passing a value through several unrelated layers of props becomes genuinely painful.
Best Practices
- Keep state as low in the component tree as possible; lift it only as far as the closest common ancestor that actually needs to share it.
- Treat lifted state as immutable: always create a new array or object with spread,
map, orfilterrather than mutating the existing one and calling the setter with the same reference. - Name props consistently so the data flow reads clearly at a glance — a value flowing down (
value,items) paired with a callback flowing up (onChange,onAdd,onRemove). - Give every list item a stable, unique
key(an id, not the array index) so React can correctly match items across re-renders once state is lifted and shared. - Derive values instead of duplicating them: if a child can compute something from a prop it already receives (like Fahrenheit from Celsius), compute it inline rather than storing a second, separate piece of state for it.
- Enforce a single source of truth: for any one piece of data, exactly one component in the tree should hold it in
useState; every other component should only receive it as a prop. - If lifting would require threading a value through several layers of components that do not use it themselves, consider
useContextinstead of continuing to drill props deeper.
Practice Exercises
- Build sibling
VolumeSliderandVolumeLabelcomponents. Lift a single numericvolumevalue to their common parent so dragging the slider updates the label's displayed text (for example, "Volume: 42") in real time. - Build a
ColorSwatchescomponent (a row of buttons, one per color) and a siblingPreviewcomponent. Lift the selected color name to their shared parent so clicking a swatch updates a sentence inPreviewsuch as "Selected color: blue". - Extend the shopping list from Example 3 by adding a third sibling component,
ItemCount, that receives only theitemsarray as a prop and renders "You have N item(s) on your list." No changes to the existing state logic should be needed — just pass the already-lifteditemsstate down to one more child.
Summary
- Lifting state up means moving shared data to the closest common parent of the components that need it, instead of duplicating it in each sibling.
- Data flows down through props; requests to change that data flow up through callback props the parent passes to its children.
- Exactly one component should be the single source of truth for any given piece of state; its siblings only ever receive it as a prop.
- A parent state update re-renders the parent and every child that receives new props, which is what keeps all consumers of that state automatically in sync.
- Always update lifted state immutably, using a new array or object, so React can correctly detect that a change occurred.
- If lifting a value forces it through many uninvolved layers of components, consider
useContextinstead of deeper prop drilling.
