What "lifting state up" actually means
Every React developer hears this phrase early on, but it's easy to treat it as abstract advice. Lifting state up has a precise, mechanical meaning: move a piece of state to the lowest ancestor component that contains all the components that need it.
That's it. No magic, no architectural ceremony — just picking the right place
in the component tree to call useState.
The reason it matters: React enforces one-way data flow. State lives in a component and flows down to descendants via props. There is no built-in channel for a child to read a sibling's state or push data upward. So when two components need to stay in sync, the only path is to give them a shared parent that owns the state and hands it down.
The classic example: sibling sync
Imagine a search page with an input bar and a results list. Both need the same query string:
// ❌ Before lifting — two independent copies of query
function SearchBar() {
const [query, setQuery] = useState('')
return <input value={query} onChange={e => setQuery(e.target.value)} />
}
function ResultsList() {
// Has no idea what SearchBar typed — can't read sibling state
return <p>No results</p>
}
The fix is to lift query to their common parent:
function SearchPage() {
const [query, setQuery] = useState('') // lifted here
return (
<>
<SearchBar query={query} onSearch={setQuery} />
<ResultsList query={query} />
</>
)
}
function SearchBar({ query, onSearch }) {
return <input value={query} onChange={e => onSearch(e.target.value)} />
}
function ResultsList({ query }) {
// Now sees the real query
return <p>Results for: {query}</p>
}
SearchPage becomes the single source of truth. Changes in SearchBar
flow up via the onSearch callback, then back down to both components as a
prop.
Child-to-parent communication via callbacks
React data flows downward, but user interactions happen in children. The bridge is a callback prop: the parent passes a function down; the child calls it when something happens.
The naming convention mirrors React's own event props:
on<Event>— the prop name the parent provides (onSearch,onChange)handle<Event>— the function implementation in the parent (handleSearch)
function Parent() {
const [count, setCount] = useState(0)
function handleIncrement(amount) {
setCount(prev => prev + amount)
}
return <Counter value={count} onIncrement={handleIncrement} />
}
function Counter({ value, onIncrement }) {
return (
<div>
<span>{value}</span>
<button onClick={() => onIncrement(1)}>+1</button>
<button onClick={() => onIncrement(5)}>+5</button>
</div>
)
}
Counter owns zero state — it is a controlled component driven entirely
by props. This makes it trivially reusable and testable.
How high should you lift?
Lift as low as you can while still covering all the consumers. Lifting unnecessarily high causes unrelated components to re-render every time the state changes.
// If query only affects SearchBar and ResultsList,
// putting it in App forces Header, Sidebar, and Footer to re-render on every keystroke
function App() {
const [query, setQuery] = useState('') // ← too high
return (
<>
<Header /> {/* re-renders needlessly */}
<Sidebar /> {/* re-renders needlessly */}
<SearchPage query={query} onSearch={setQuery} />
</>
)
}
// SearchPage is the right level — it contains both consumers
function SearchPage() {
const [query, setQuery] = useState('') // ← correct level
return (
<>
<SearchBar query={query} onSearch={setQuery} />
<ResultsList query={query} />
</>
)
}
The rule: lowest common ancestor.
Derived state — don't lift what you can compute
A common mistake after lifting is creating additional state for values that can be calculated from existing state. This creates two sources of truth that can drift.
// ❌ Redundant — fullName can diverge from first/last
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [fullName, setFullName] = useState('') // don't do this
// ✅ Derive it — always in sync, zero extra state
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const fullName = `${firstName} ${lastName}` // computed inline
Derived state is not lifted state — it's computed from lifted state. The distinction keeps your state minimal and avoids sync bugs.
Forms: lifting state enables validation and submission
Lifting form fields into the parent that owns the submit action is the cleanest pattern for controlled forms:
function SignupForm() {
const [form, setForm] = useState({ name: '', email: '' })
const isValid = form.name.length > 0 && form.email.includes('@')
function handleChange(field) {
return (e) => setForm(prev => ({ ...prev, [field]: e.target.value }))
}
function handleSubmit(e) {
e.preventDefault()
if (isValid) api.signup(form)
}
return (
<form onSubmit={handleSubmit}>
<input value={form.name} onChange={handleChange('name')} />
<input value={form.email} onChange={handleChange('email')} />
<button type="submit" disabled={!isValid}>Sign up</button>
</form>
)
}
Because all field values are lifted into form, the submit handler sees a
consistent snapshot with no DOM queries.
Stable callback references with useCallback
When you pass a callback down to a memoised child, a new function reference on
every render defeats the memoisation. Wrap with useCallback:
function Parent() {
const [items, setItems] = useState([])
const handleAdd = useCallback((item) => {
setItems(prev => [...prev, item])
}, []) // stable reference
return <ExpensiveList onAdd={handleAdd} items={items} />
}
const ExpensiveList = React.memo(({ items, onAdd }) => {
// Only re-renders when items or onAdd actually change
return (
<>
{items.map(i => <span key={i}>{i}</span>)}
<button onClick={() => onAdd('new')}>Add</button>
</>
)
})
useCallback is most valuable when the callback is the only reason a
memoised child would re-render.
When to stop lifting and reach for Context or a store
Lifting state is the right default, but it has limits:
| Situation | Better approach |
|---|---|
| State needed 1–2 levels down | Lift (props) |
| State needed across many unrelated subtrees | Context API |
| State changes frequently with many subscribers | Zustand / Redux |
| Complex update logic | useReducer (can still be lifted) |
The overhead of lifting grows with tree depth. When you find yourself passing a prop through three or more layers that don't use it themselves, that's the signal to evaluate Context or composition instead.
The initialX prop convention
When a child seeds its own state from a parent prop — not staying in sync, just
using it as a starting value — prefix the prop with initial to make the
contract explicit:
function EditableTitle({ initialTitle }) {
const [title, setTitle] = useState(initialTitle) // seeded once
return <input value={title} onChange={e => setTitle(e.target.value)} />
}
// Caller knows updates won't flow back without a callback
<EditableTitle initialTitle="Untitled Document" />
This communicates clearly: "I gave you a starting value; you own it now."
Key interview points
- Lifting state is about finding the lowest common ancestor — not reflexively moving everything to the top.
- Children communicate upward via callback props named
onX. - Don't duplicate state — derive computed values instead.
- Lift only as high as needed to avoid unnecessary re-renders.
- Prefer lifting before reaching for Context; prefer Context before reaching for a store.