What is state colocation and why does it matter
Put state as close as possible to where it's used. The wrong default — 'lift everything to App' — re-renders the whole tree for any change and obscures ownership. Colocation: input state in the input, modal-open state in the modal trigger, filter state in the filter panel. Lift only when multiple siblings need it; even then, lift to the lowest common ancestor.
State colocation is the practice of keeping state as close as possible to where it's actually read and written. It's a quiet but high-leverage habit — it makes apps faster, easier to refactor, and easier to reason about.
The anti-pattern
function App() {
// ALL state hoisted here
const [searchQuery, setSearchQuery] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [theme, setTheme] = useState("light");
const [cart, setCart] = useState([]);
// ...50 more
return <Layout {...allTheProps} />;
}Every change re-renders the entire app. State ownership is opaque — a contributor reading App.jsx has to guess who uses what. The "lift first, optimize later" default leads here.
The colocated alternative
function App() {
// Only truly global state
return (
<ThemeProvider>
<CartProvider>
<Layout />
</CartProvider>
</ThemeProvider>
);
}
function SearchBox() {
const [query, setQuery] = useState(""); // local to where it's used
// ...
}
function ProfileMenu() {
const [open, setOpen] = useState(false); // local to this menu
// ...
}Why it matters
Performance
A state change re-renders the component holding the state and its children. Local state → narrow re-render. Hoisted state → wide re-render.
Colocation is the cheapest performance optimization — no memoization needed.
Ownership clarity
When state lives next to the component that uses it, you don't have to trace where it comes from. A contributor reads one file.
Refactor safety
Removing a feature means removing one file. With hoisted state, you've got dangling props and unused state across the tree.
Testability
Components with local state can be tested without setting up a global store.
When to lift
Only when multiple siblings need to share state. Even then:
- Lift only to the lowest common ancestor.
- Pass via props if the tree is shallow.
- Reach for context only when prop-drilling crosses many layers or many consumers exist.
- Reach for a store (Zustand, Redux) only when context's "every consumer re-renders" model hurts.
The "right" mental hierarchy
- Local
useState— default. - Lift to nearest common parent — when 2-3 siblings need it.
- Context — for theme, auth, locale — things many components read, rarely changes.
- External store — for hot, frequently-changing app state with many subscribers.
Don't skip steps. Most apps need much less of (3) and (4) than they think.
Server state is different
For server state (data fetched from APIs), use React Query / SWR / TanStack Query — they're caches keyed by query, not "state colocation" in the local sense. Don't try to colocate server data; it's shared by nature.
Concrete examples
Form input
function NameField() {
const [name, setName] = useState("");
// ...
}Don't lift to a Form component just because "maybe the form needs it." Lift when submit actually needs to assemble.
Modal open state
function DeleteButton() {
const [confirmOpen, setConfirmOpen] = useState(false);
return (<><button onClick={() => setConfirmOpen(true)}>Delete</button>
{confirmOpen && <ConfirmModal ... />}</>);
}The trigger owns the modal. Don't hoist to App.
Filter panel state
If only the filter panel and the list need filters, lift to the page component (their common parent). Don't go higher.
Anti-patterns
- "State should live in Redux because it's app state." No — most state is local.
- "useState here, then I'll move it later." Move on the way down (colocate) more often than up.
- "Context for everything." Every Provider value change re-renders every consumer.
Interview framing
"Colocation means putting state next to where it's used. The default of 'hoist state to App' looks like good architecture but it slows the app down (wide re-renders), obscures ownership (opaque dependencies), and complicates refactors (state outlives the features). I default to local useState, lift only when multiple siblings need the value, and even then to the lowest common ancestor. Context for things many components read but that change rarely (theme, auth). External stores only for hot, frequently-changing state with many subscribers. Server state is separate — that lives in a query cache, not in local state. The principle: state should be as local as it can be."
Follow-up questions
- •When should you lift state vs colocate?
- •Why is hoisting everything an anti-pattern?
- •When does context become the right answer?
- •How does this affect performance?
Common mistakes
- •Lifting state to App by default.
- •Context with frequently-changing values.
- •Redux for purely local state.
- •Refactoring local state into 'global' just to share with a sibling — lift, don't globalize.
Performance considerations
- •Colocation IS a perf optimization — narrow re-renders without memoization. Often the simplest fix to 'why is my app slow'.
Edge cases
- •Form state shared between input, validation, and submit — lift to the form.
- •Wizard state that survives step transitions — lift above the steps.
- •Cross-app concerns like cart, auth — global is correct.
Real-world examples
- •Dan Abramov's 'Before You memo()' post on colocation.
- •Form libraries (React Hook Form) that intentionally colocate field state.