Lesson 31 of 80
Two sibling components can't share state directly — the fix isn't a new API, it's moving the state to the nearest component that renders both of them.
12 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
React data flows one direction: down, through props. Two sibling components have no direct line to each other at all — PanelA cannot read PanelB's state, and there's no built-in way for them to coordinate.
Lifting state up means: find the nearest common ancestor of the components that need to share a value, move the useState call up to live there, and pass the value and the setter (or a callback) back down as props. The siblings stop owning the state themselves and become purely controlled by their parent.
Start with the broken version — each panel manages its own isOpen independently:
function Panel({ title, children }) {
const [isOpen, setIsOpen] = useState(false); // 🐛 lives in isolation
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>{title}</button>
{isOpen && <div>{children}</div>}
</div>
);
}
Every Panel is an island. Opening one has no way to close the others, because nothing outside a single Panel can even see its isOpen.
Lifting the state up fixes it — the state moves to Accordion, and each Panel becomes controlled:
function Accordion() {
const [openId, setOpenId] = useState(null); // lives one level up now
return (
<>
<Panel
title="Shipping"
isOpen={openId === "shipping"}
onToggle={() => setOpenId(openId === "shipping" ? null : "shipping")}
>
Ships in 3-5 days.
</Panel>
<Panel
title="Returns"
isOpen={openId === "returns"}
onToggle={() => setOpenId(openId === "returns" ? null : "returns")}
>
30-day window.
</Panel>
</>
);
}
function Panel({ title, isOpen, onToggle, children }) {
return (
<div>
<button onClick={onToggle}>{title}</button>
{isOpen && <div>{children}</div>}
</div>
);
}
Panel no longer has a useState call at all — it just renders whatever isOpen it's handed and calls onToggle when clicked. Accordion is the single source of truth, so it can trivially enforce "only one panel open" because it's the one place that sees every panel's state at once.
useState call to a point in the tree where its value can reach every component that needs it via ordinary props.Panel can't set openId itself — it doesn't own that state anymore — so it calls onToggle, and the parent decides what actually happens. This is the same controlled-component shape you'd see on a form input, just applied to arbitrary state instead of a text value.Panel also tracked something purely local — like a hover-highlight flag nothing else cares about — that stays in Panel. Lifting is selective, not all-or-nothing.Keeping state as close as possible to where it's used is the right default — moving it up costs re-render scope and indirection. Lifting state up is what you do specifically when two components stop being independent and start needing to agree on one value; it's the deliberate exception to that default, not a replacement for it. When the sharing need grows past a couple of levels — many descendants, not just two nearby siblings — passing the value down through props at every layer stops being practical, which is the exact problem the next step up in this track exists to solve.