Lifting state up means moving shared state to the lowest common ancestor of the components that need it. The ancestor owns the state and passes both the value and a setter callback down as props, so siblings can read the same data and trigger updates through a common parent.
function Parent() {
const [value, setValue] = useState('')
return (
<>
<Input value={value} onChange={setValue} />
<Preview value={value} />
</>
)
}
// Input writes, Preview reads — state lives in Parent
Rule of thumb: If two components need to stay in sync, lift their shared state to the closest ancestor that contains both of them.
React enforces one-way data flow — a child cannot directly read or write a sibling's state. If two components each hold their own copy of the same value, those copies can diverge and the UI becomes inconsistent.
// ❌ Two independent copies — can get out of sync
function A() { const [x, setX] = useState(0); ... }
function B() { const [x, setX] = useState(0); ... }
// ✅ One source of truth in the common parent
function Parent() {
const [x, setX] = useState(0)
return <><A x={x} onChange={setX} /><B x={x} /></>
}
A single source of truth makes behaviour predictable: any change flows down from the owner and every consumer automatically re-renders with the latest value.
Rule of thumb: If you ever copy state from props into another
useState, you almost certainly have a sync problem waiting to happen.
The parent passes a callback prop (e.g. onChange, onSubmit) to
the child. When the user interacts, the child calls that callback with
the new value. The parent's setter updates state, which flows back down.
function Parent() {
const [qty, setQty] = useState(1)
return <QuantityInput value={qty} onChange={setQty} />
}
function QuantityInput({ value, onChange }) {
return (
<input
type="number"
value={value}
onChange={e => onChange(Number(e.target.value))}
/>
)
}
The child owns zero state here — it is a controlled component driven entirely by props.
Rule of thumb: Name callbacks onX in the parent and handleX
inside the child to keep the intent clear.
In their closest common ancestor. That ancestor becomes the single source of truth and distributes both the value and update callbacks.
// Siblings: SearchBar and ResultsList need the same query string
function SearchPage() {
const [query, setQuery] = useState('')
return (
<div>
<SearchBar query={query} onSearch={setQuery} />
<ResultsList query={query} />
</div>
)
}
Avoid the temptation to let one sibling own the state and pass it sideways — React has no sibling-to-sibling channel.
Rule of thumb: Siblings communicate via a shared parent, never directly.
Every component that receives the state as a prop will re-render when that state changes — including ones that don't use it but happen to sit between the owner and the consumer. This causes unnecessary re-renders and can hurt performance in large trees.
// If `query` lives in App, every child of App re-renders on each
// keystroke — even Header, Sidebar, Footer that don't care about query
function App() {
const [query, setQuery] = useState('')
return (
<>
<Header /> {/* re-renders needlessly */}
<Sidebar /> {/* re-renders needlessly */}
<SearchPage query={query} onSearch={setQuery} />
</>
)
}
Keep state as low in the tree as possible while still being shared where needed.
Rule of thumb: Lift only as high as required; don't promote state pre-emptively "in case" something else needs it.
| Scenario | Approach |
|---|---|
| 1–2 levels of passing, few consumers | Lift state (props) |
| Deeply nested tree, many consumers, changes infrequently | Context |
| Frequently updated, many subscribers, complex logic | External store (Redux, Zustand) |
Lifted state is the simplest solution and should be the default. Context adds an implicit dependency and can cause broad re-renders. A global store adds a dependency and operational complexity.
// Fine with lifting (shallow):
<Page><FilterBar /><Table /></Page>
// Consider Context (deeply nested):
<App><Layout><Sidebar><DeepWidget /></Sidebar></Layout></App>
Rule of thumb: Start with lifting. Reach for Context only when prop drilling becomes painful; reach for a store only when Context causes performance problems.
When you lift state into a parent and pass value + onChange to an
input, that input becomes a controlled component — the parent's
state is the single source of truth for the field's current value.
// Parent owns the state → input is controlled
function Form() {
const [email, setEmail] = useState('')
return (
<input
value={email} // controlled
onChange={e => setEmail(e.target.value)}
/>
)
}
Without lifting, each input would manage its own uncontrolled state (the DOM), making it harder for the parent to read or validate values.
Rule of thumb: Forms almost always need lifted, controlled state so the parent can validate and submit the combined field values.
Derived state is any value you can compute from existing state or
props. It should not be duplicated in a second useState — compute
it inline during render instead.
// ❌ Redundant state — fullName can diverge
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [fullName, setFullName] = useState('') // duplicated!
// ✅ Derive it — always in sync, zero extra state
const fullName = `${firstName} ${lastName}`
Lifting state and avoiding derived copies are two sides of the same coin: both enforce a single source of truth.
Rule of thumb: If a value can be computed from existing state, don't store it separately — just compute it.
Wrap the callback in useCallback so the function reference is stable
between renders. Pair it with React.memo on the child to skip
re-renders when neither value nor callback changed.
function Parent() {
const [count, setCount] = useState(0)
const handleChange = useCallback((val) => {
setCount(val)
}, []) // stable reference
return <ExpensiveChild onChange={handleChange} count={count} />
}
const ExpensiveChild = React.memo(({ count, onChange }) => {
// only re-renders when count or onChange actually changes
return <input value={count} onChange={e => onChange(Number(e.target.value))} />
})
Without useCallback, a new function is created every render, breaking
React.memo's shallow equality check.
Rule of thumb: useCallback is most valuable when the callback is
passed to a memoized child — otherwise it's premature optimisation.
Group them in one object (or useReducer) when they change together
or are conceptually coupled. Keep them separate when they change
independently.
// ✅ Coupled — lift as one object
const [position, setPosition] = useState({ x: 0, y: 0 })
// Update: setPosition(prev => ({ ...prev, x: newX }))
// ✅ Independent — separate calls
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
Grouping reduces the number of props you thread through the tree; keeping them separate avoids unnecessary object spread on every update.
Rule of thumb: If you always update both values at the same time, group them; if you update them independently, keep them separate.
Pass the initial value as the useState initialiser argument. React
only evaluates it on the first render — subsequent prop changes are
intentionally ignored because the state is now owned by the parent.
// ❌ Anti-pattern: syncing prop to state on every render
function Child({ initialCount }) {
const [count, setCount] = useState(initialCount)
useEffect(() => { setCount(initialCount) }, [initialCount])
// now state and prop fight for ownership
}
// ✅ Use initialValue convention — document that updates won't flow down
function Counter({ initialCount }) {
const [count, setCount] = useState(initialCount) // first render only
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
If ongoing sync is required, the parent should own the state and pass a controlled value + callback instead.
Rule of thumb: Prefix props that seed state only once with
initial (initialCount, initialValue) to signal they're not
kept in sync.
Use onX for props that accept a callback and handleX for the
function itself — mirroring React's own onClick, onChange, etc.
// Parent — owns state, names the prop onSearch
function SearchPage() {
const [query, setQuery] = useState('')
function handleSearch(q) { setQuery(q) }
return <SearchBar onSearch={handleSearch} />
}
// Child — receives the prop, calls it when the user acts
function SearchBar({ onSearch }) {
return <input onChange={e => onSearch(e.target.value)} />
}
Consistent naming makes it immediately obvious at the call site which prop triggers parent behaviour.
Rule of thumb: on prefix = prop the caller provides;
handle prefix = the local function that implements it.
Unidirectional data flow means data travels in one direction only: down the tree via props. A child can signal a change (via callback), but the parent decides whether and how state updates, then the new value flows back down.
Parent state
↓ props
Child A Child B
↑ callback (event)
This makes the state transitions explicit, predictable, and easy to debug — you always know where a piece of data lives and who can change it.
Rule of thumb: If you find yourself needing to pass data upward without a callback, that's a sign the state should live higher or in a shared store.
When you copy a prop into useState, you create two sources of truth.
If the parent updates the prop, the child's copy stays stale unless you
add a useEffect to sync — which introduces complexity and can cause
one-render lag bugs.
// ❌ Derived state anti-pattern
function Child({ value }) {
const [localValue, setLocalValue] = useState(value)
// If parent changes `value`, localValue is now stale
useEffect(() => setLocalValue(value), [value]) // band-aid
}
// ✅ Just use the prop directly
function Child({ value, onChange }) {
return <input value={value} onChange={e => onChange(e.target.value)} />
}
The key insight: if a component should be driven by the parent, make it fully controlled — no local copy.
Rule of thumb: Before writing useState(someProp), ask whether the
child really needs to own that state. Usually the answer is no.
With lifted state, the parent holds every field value and can read them all in the submit handler without querying the DOM.
function SignupForm() {
const [form, setForm] = useState({ name: '', email: '' })
function handleChange(field, value) {
setForm(prev => ({ ...prev, [field]: value }))
}
function handleSubmit(e) {
e.preventDefault()
api.signup(form) // all values available here
}
return (
<form onSubmit={handleSubmit}>
<TextInput label="Name" value={form.name} onChange={v => handleChange('name', v)} />
<TextInput label="Email" value={form.email} onChange={v => handleChange('email', v)} />
<button type="submit">Sign up</button>
</form>
)
}
Rule of thumb: Lift all form fields into the parent that owns the submit action so validation and submission see a consistent snapshot.
Store the list in the parent as an array of objects. Pass each item and
an onChange callback that identifies the item by index or id.
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Buy milk', done: false },
{ id: 2, text: 'Walk dog', done: false },
])
function handleToggle(id) {
setTodos(prev =>
prev.map(t => t.id === id ? { ...t, done: !t.done } : t)
)
}
return todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
))
}
function TodoItem({ todo, onToggle }) {
return (
<label>
<input type="checkbox" checked={todo.done}
onChange={() => onToggle(todo.id)} />
{todo.text}
</label>
)
}
The parent is the single source of truth for the entire list; each child is a controlled component.
Rule of thumb: Always identify list items by a stable id (not
index) when items can be added, removed, or reordered.
More State and Data Flow interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.