Colocate state until you can't
The right home for a piece of state is the smallest scope that needs it. Colocation means keeping state as close as possible to where it is used — in the component that owns it — and only lifting it higher when something higher genuinely needs it. The common anti-pattern is the opposite: reaching for a global store by default, so a modal’s open flag, a form’s draft, and a hover state all end up in Redux “to be safe.” That is how a simple app grows a state-management problem it never had. Global state is not the responsible default; it is the tool you escalate to when colocation stops working, and knowing when it stops is the whole skill.
Start local
If one component uses a piece of state, it lives in that component. A search box’s query, a disclosure’s open flag, a hovered index — none of these need to exist anywhere but where they are used:
function Disclosure({ children }) {
const [open, setOpen] = useState(false); // nobody else needs this — keep it here
return (
<>
<button onClick={() => setOpen((o) => !o)}>Toggle</button>
{open && <div>{children}</div>}
</>
);
}
This is not laziness; it is correctness. State this local has the smallest possible blast radius — you can read the component and know everything that can change it.
Lift only when a sibling needs it
When a second component needs the same state, lift it to their nearest common parent — no further. Two inputs that must stay in sync share a parent’s state; they do not need a global store:
function RangeFilter() {
const [min, setMin] = useState(0);
const [max, setMax] = useState(100); // shared by exactly two children, lifted one level
return (
<>
<NumberInput value={min} onChange={setMin} />
<NumberInput value={max} onChange={setMax} />
<Results min={min} max={max} />
</>
);
}
“Lift to the nearest common ancestor” is the rule — lifting higher than necessary just widens the blast radius again.
Escalate to a store only when colocation breaks
Global state earns its place when a piece of state is needed by many components, far apart in the tree, and passing it as props would mean threading it through a dozen layers that do not care (“prop drilling”). A logged-in user, a theme, a cart touched from the header and three pages — those genuinely belong in a store or context. But notice the two rungs people skip on the way up: the URL is the right home for shareable view state (filters, tabs), and server data belongs in a query cache, not the store. Colocation is the default because local state is the easiest to understand, test, and delete; global state is the exception you escalate to when sharing demands it. Reach for it deliberately — “many consumers, far apart, and the URL isn’t the answer” — not reflexively. The counter-component exercise starts state at the local rung, and query-string-state is the URL rung, which together cover most of what people wrongly send straight to a global store.