Skip to the content.

Colocate state until you can't

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.

Escalate state scope only as far as sharing requires A ladder of scopes: local component state, lifted to a common parent, the URL, and finally a global store — each step taken only when the previous can no longer reach all the consumers. local (useState)one component lifted to parenta few siblings URLshareable view global storemany, far apart only climb a rung when the current one can't reach all consumers
Start at the bottom rung and climb only when forced: local, then lifted, then the URL, then a global store. Each step costs indirection, so earn it.

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.