Difficulty: Intermediate
What is state colocation in React? When should you lift state up?
State colocation is one of those principles that sounds obvious when you hear it, but a surprising number of React codebases get it wrong. The idea is simple: keep state as close as possible to where it's used. But let's dig into what that actually means in practice and why it matters so much.
Picture your React component tree as a big upside-down tree. At the top is your App component, and it branches down through layouts, pages, sections, and individual UI elements. State colocation says: don't put state at the top of the tree if it's only used at the bottom. Put it at the bottom, right next to the component that needs it.
For example, think about a dropdown menu's open/closed state. Only the menu component cares about whether it's open. The page doesn't care. The app shell doesn't care. The user's profile data doesn't change when the menu opens. So that isOpen state should live inside the menu component as a local useState, not in a global Zustand store or a React Context at the top of the tree.
Why does this matter? Because in React, when state changes, the component holding that state and all its descendants re-render. If you put isMenuOpen in a context at the root of your app, every component that consumes that context re-renders when the menu opens. That's potentially hundreds of components re-rendering because a dropdown opened. Put it in the menu component, and only the menu re-renders.
Now, the flip side: sometimes state genuinely needs to be shared. That's where lifting state up comes in. The classic example from the React docs is a temperature converter with a Celsius input and a Fahrenheit input. If each input manages its own state independently, they can't stay in sync. The Celsius input doesn't know what the Fahrenheit input says, and vice versa.
The solution is to lift the state up to their closest common ancestor. The parent component holds the celsius state as the single source of truth. The Fahrenheit value is computed from it (celsius * 9/5 + 32). When either input changes, it updates the parent's celsius state, and both inputs stay in sync. This is the canonical React pattern for shared state between siblings.
But here's the subtlety: lift only as high as necessary. If two components three levels apart need to share state, lift it to their lowest common ancestor, not to the root of the app. Lifting too high causes the same problem as premature global state -- unnecessary re-renders of unrelated components.
Let me give you a mental framework for deciding where state should live:
Level 1 - Component state (useState): UI state that only one component cares about. Toggle states, form inputs, hover states, local loading indicators.
Level 2 - Lifted state: When two sibling components need to share or coordinate. Lift to the parent. Temperature converter, expandable panels where only one can be open.
Level 3 - Context: When state needs to be accessed by components far apart in the tree. Theme (light/dark), locale (language), authenticated user. But be careful -- context causes all consumers to re-render on any change, so keep context values narrow.
Level 4 - External store (Zustand, Redux): When you have complex state with many updaters, when you need state to persist across route changes, or when the state logic is complex enough to benefit from middleware and devtools.
Level 5 - URL state (useSearchParams): State that should be shareable via URL. Filters, sorting, pagination, selected tab, search queries. This is the most underused level. If a user should be able to bookmark or share a URL and see the same view, that state belongs in the URL, not in React state.
Level 6 - Server state (React Query / SWR): Data fetched from an API. This is a separate category entirely. Server state has its own concerns: caching, invalidation, background refetching, optimistic updates. Libraries like React Query handle these concerns much better than useState or Redux ever could.
A common anti-pattern I see in real codebases is dumping everything into a global store. Redux made this worse because its boilerplate (actions, reducers, selectors) felt like 'real architecture,' so developers would put everything there -- form state, modal visibility, dropdown selections, loading flags. This creates a tangled mess where changing one piece of state triggers selectors and re-renders across the entire app.
The React team themselves recommend starting with local state, then reaching for context, and only using external stores when you genuinely need them. State colocation isn't just a performance optimization -- it's a code organization principle. When state lives next to the component that uses it, the code is easier to understand, easier to refactor, and easier to delete when that feature is removed.
// Siblings need to share state - lift to parent
// BEFORE: duplicated or mismatched state
function TemperatureConverter() {
return (
<>
<CelsiusInput /> {/* Has its own state */}
<FahrenheitInput /> {/* Cannot see Celsius state */}
</>
);
}
// AFTER: lift state to common parent
function TemperatureConverterFixed() {
const [celsius, setCelsius] = useState(0);
const fahrenheit = celsius * 9/5 + 32;
return (
<>
<CelsiusInput
value={celsius}
onChange={(c) => setCelsius(Number(c))}
/>
<FahrenheitInput
value={fahrenheit}
onChange={(f) => setCelsius((Number(f) - 32) * 5/9)}
/>
</>
);
}
function CelsiusInput({ value, onChange }) {
return (
<label>
Celsius:
<input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
The parent owns the celsius state as the single source of truth. Both inputs derive from it - Fahrenheit is computed, not stored.
// BAD: Putting UI state in global store
const store = {
isMenuOpen: false, // UI state - should be local!
isModalOpen: false, // UI state - should be local!
selectedTabIndex: 0, // UI state - should be local!
users: [], // Server state - belongs in store/cache
currentUser: null, // Global state - belongs in store
};
// GOOD: Colocate UI state
function Navbar() {
const [isMenuOpen, setIsMenuOpen] = useState(false); // Local
const currentUser = useAuthStore(s => s.user); // Global (shared)
return (
<nav>
<button onClick={() => setIsMenuOpen(v => !v)}>Menu</button>
{isMenuOpen && <DropdownMenu />}
{currentUser && <UserAvatar user={currentUser} />}
</nav>
);
}
// URL as state for shareable filters
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') || 'all';
const sort = searchParams.get('sort') || 'price';
// Filter state lives in URL - bookmarkable and shareable
return (
<>
<Filters
category={category}
sort={sort}
onChange={(key, val) => setSearchParams(prev => ({ ...Object.fromEntries(prev), [key]: val }))}
/>
<ProductGrid category={category} sort={sort} />
</>
);
}
UI state (menu open, modal, tabs) should stay local. Global store is for truly shared state. URL is the right place for shareable filter state.
State Colocation, Lifting State Up, State Management, Single Source of Truth