Lifting State Up
“Lifting state up” means moving shared state to the lowest common ancestor of the components that need it, then passing values and setters down via props. It’s the cleanest answer to “two siblings need to read or change the same thing” without reaching for a state library.
Pattern, props down, events up
EXAMPLE
// 1) The smell — duplicate state in siblings
function CelsiusInput() {
const [c, setC] = useState(0);
return <input value={c} onChange={(e) => setC(+e.target.value)} />;
}
function FahrenheitDisplay() {
// No way to read CelsiusInput's c → can't sync.
return <p>?</p>;
}
// 2) Lift state to a shared parent
function TempConverter() {
const [c, setC] = useState(0);
return (
<>
<CelsiusInput value={c} onChange={setC} />
<FahrenheitDisplay celsius={c} />
</>
);
}
function CelsiusInput({ value, onChange }) {
return <input type="number" value={value} onChange={(e) => onChange(+e.target.value)} />;
}
function FahrenheitDisplay({ celsius }) {
return <p>{(celsius * 9) / 5 + 32}°F</p>;
}
// State lives ONCE in the parent. Both children stay in sync; testing the parent tests the flow.
// 3) Bi-directional — both inputs can drive
function TempBidirectional() {
const [c, setC] = useState(0);
return (
<>
<input type="number" value={c} onChange={(e) => setC(+e.target.value)} />
<input type="number" value={(c * 9) / 5 + 32} onChange={(e) => setC(((+e.target.value) - 32) * 5 / 9)} />
</>
);
}
// 4) Real-world — table + filter sharing state
function Inventory({ products }) {
const [query, setQuery] = useState('');
const [inStock, setInStock] = useState(false);
const filtered = products.filter((p) => (
(!inStock || p.stock > 0) &&
p.name.toLowerCase().includes(query.toLowerCase())
));
return (
<>
<SearchBar query={query} onQuery={setQuery} inStock={inStock} onInStock={setInStock} />
<ProductTable products={filtered} />
</>
);
}
function SearchBar({ query, onQuery, inStock, onInStock }) {
return (
<div>
<input value={query} onChange={(e) => onQuery(e.target.value)} placeholder="Search" />
<label><input type="checkbox" checked={inStock} onChange={(e) => onInStock(e.target.checked)} /> in stock</label>
</div>
);
}
// 5) When NOT to lift
// • State is ONLY used by one child — keep it local
// • State is needed deep + shallow → Context or a state library
// • You'd be lifting through 5+ levels of components — prop-drilling smell
// 6) Lifting vs Context vs Library
// Lifting — siblings + small trees (2-3 levels)
// Context — many descendants, doesn't change often (theme, auth)
// Zustand/Jotai — many components, frequent updates, derived state
// Redux / RTK — large apps + dev tools + time-travel debugging
// Server state — TanStack Query / SWR / RTK Query (don't store in state at all)
// 7) Reducer pattern — when lifted state has many actions
import { useReducer } from 'react';
function reducer(state, action) {
switch (action.type) {
case 'set': return { ...state, [action.key]: action.value };
case 'reset': return initialState;
case 'clear': return { ...state, items: [] };
default: throw new Error(action.type);
}
}
function CartProvider() {
const [state, dispatch] = useReducer(reducer, initialState);
return <Cart items={state.items} onAdd={(it) => dispatch({ type: 'set', key: 'items', value: [...state.items, it] })} />;
}
// 8) Container / presentational split
// Container holds state + handlers; presentational components are pure (data in, callbacks out).
function TodoContainer() {
const [todos, setTodos] = useState([]);
return (
<TodoView
todos={todos}
onAdd={(text) => setTodos([...todos, { id: Date.now(), text }])}
onToggle={(id) => setTodos(todos.map((t) => t.id === id ? { ...t, done: !t.done } : t))}
/>
);
}
// 9) Common bugs
// • Lifting state to too high an ancestor → unnecessary re-renders deep in the tree
// • Lifting + new object/array each render → child memo defeated; useMemo / useCallback
// • Two sources of truth (parent state + child useState) → drift
// • Forgetting to lift event handlers along with state → child still mutates its own copy
// • Lifting too soon for a one-off → premature abstraction; wait for the second use case
// • Passing setters that mutate vs replace — always set { ...state, key: value }, never state.key = value
Why it matters
Lift state to the lowest common ancestor and pass values + setters down. It’s the right answer for siblings sharing a piece of state and the foundation of more advanced patterns (reducers, container/presentational). When lifting drags you through more than three levels or many unrelated descendants need the same value, graduate to Context or a state library.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Move shared state to the closest common parent.
function Parent() {
const [v, setV] = useState('');
return <><Input value={v} onChange={setV} /><Display value={v} /></>;
}
Try it Yourself »
Discussion
Loading…