State Colocation
State colocation is the practice of keeping state as close as possible to the component that uses it. It is the counterpart to lifting state up: instead of always pushing state toward the root, you push it as far down the tree as it will go while still serving all its consumers.
The principle is simple: state should live at the lowest common ancestor of all the components that need it. Not higher. When you lift state unnecessarily, more of the tree re-renders than needed, and the relationship between state and the UI that uses it becomes harder to follow.
Why Colocated State Is Faster
When a piece of state changes, React re-renders the component that owns it and all of its descendants. State owned higher in the tree means a larger subtree re-renders on every update. State owned as low as possible minimises the blast radius:
// ✗ Bad: filter state lives at the top of a large tree
//
// App (owns filterQuery state)
// ├── Header ← re-renders on every keystroke (doesn't use filter)
// ├── Sidebar ← re-renders on every keystroke (doesn't use filter)
// └── ProductSection
// └── FilterBar ← re-renders (good — actually uses filter)
// └── ProductGrid ← re-renders (good — uses filter)
function App() {
const [filterQuery, setFilterQuery] = useState('')
return (
<>
<Header /> {/* wasted render on every keystroke */}
<Sidebar /> {/* wasted render on every keystroke */}
<ProductSection
filterQuery={filterQuery}
onFilterChange={setFilterQuery}
/>
</>
)
}// ✓ Good: filter state colocated in ProductSection
//
// App
// ├── Header ← never re-renders when filter changes
// ├── Sidebar ← never re-renders when filter changes
// └── ProductSection (owns filterQuery state)
// └── FilterBar ← re-renders (good)
// └── ProductGrid ← re-renders (good)
function App() {
return (
<>
<Header />
<Sidebar />
<ProductSection /> {/* manages its own filtering internally */}
</>
)
}
function ProductSection() {
// State lives here — only this subtree re-renders on filter change
const [filterQuery, setFilterQuery] = useState('')
const filtered = products.filter(p =>
p.name.toLowerCase().includes(filterQuery.toLowerCase())
)
return (
<>
<FilterBar query={filterQuery} onChange={setFilterQuery} />
<ProductGrid products={filtered} />
</>
)
}In the colocated version, typing in the filter input only re-renders ProductSection and its children. Header and Sidebar are completely unaffected.
Refactoring: Moving State Down
The most common colocation refactor is recognising that a piece of state was lifted higher than necessary. Here is a step-by-step example with a modal that was incorrectly owned at the root:
// ✗ Before: modal open state lives at the root
function App() {
const [isDeleteModalOpen, setDeleteModalOpen] = useState(false)
const [productToDelete, setProductToDelete] = useState(null)
return (
<>
<Navbar /> {/* never uses modal state — but re-renders when it changes */}
<ProductList
onDeleteRequest={(product) => {
setProductToDelete(product)
setDeleteModalOpen(true)
}}
/>
<DeleteModal
isOpen={isDeleteModalOpen}
product={productToDelete}
onClose={() => setDeleteModalOpen(false)}
/>
</>
)
}
// ✓ After: modal state colocated in ProductList (or a dedicated container)
function App() {
return (
<>
<Navbar />
<ProductListWithDelete /> {/* self-contained — owns its own modal */}
</>
)
}
function ProductListWithDelete() {
// Both the list and the modal live here — zero prop drilling, zero wasted renders
const [isDeleteModalOpen, setDeleteModalOpen] = useState(false)
const [productToDelete, setProductToDelete] = useState(null)
return (
<>
<ProductList
onDeleteRequest={(product) => {
setProductToDelete(product)
setDeleteModalOpen(true)
}}
/>
<DeleteModal
isOpen={isDeleteModalOpen}
product={productToDelete}
onClose={() => setDeleteModalOpen(false)}
/>
</>
)
}The Golden Rule: Global State = Global Re-renders
Global state managers — whether the Context API, Redux, or Zustand — make state available everywhere. But that convenience comes at a cost: every subscriber can re-render on every update. The more state you push into a global store, the larger the surface area for unnecessary re-renders.
// ✗ Everything in the global store — UI toggle state does not belong here
const useStore = create(set => ({
user: null, // ✓ global — needed everywhere
cartItems: [], // ✓ global — needed everywhere
products: [], // ✓ global — needed in many places
isSidebarOpen: false, // ✗ local UI state — only Sidebar cares about this
activeTab: 'all', // ✗ local UI state — only ProductTabs cares about this
searchQuery: '', // ✗ local UI state — only SearchBar cares about this
}))
// ✓ Keep UI toggle state local; only put genuinely shared data in global state
const useStore = create(set => ({
user: null,
cartItems: [],
products: [],
}))
function Sidebar() {
const [isOpen, setIsOpen] = useState(false) // local, colocated
// ...
}
function ProductTabs() {
const [activeTab, setActiveTab] = useState('all') // local, colocated
// ...
}When Colocated State Is the Wrong Choice
Colocation is the default, but it is not always right. Move state up (or into a shared context) when:
Two or more non-ancestor components need the same value — they must share a common parent, so lift to that parent.
A child needs to communicate a result to a sibling — lift the state to the shared parent and pass a callback down.
Persistence across unmount/remount is needed — colocated state is destroyed when its component unmounts. Lift it up (or use a global store) if the value must survive.
Deep prop drilling becomes painful — at 3+ levels, consider lifting into a Context instead of threading props.
Colocation + Custom Hooks
Custom hooks are the perfect companion for colocation. They let you keep state colocated inside a component while extracting the logic into a reusable, testable unit. The state still lives in the calling component's scope — the hook is just a way to organise the code:
// The logic is extracted but the state is still LOCAL to the caller
function useProductFilter(products) {
const [query, setQuery] = useState('')
const [category, setCategory] = useState('all')
const filtered = products.filter(p => {
const matchesQuery = p.name.toLowerCase().includes(query.toLowerCase())
const matchesCategory = category === 'all' || p.category === category
return matchesQuery && matchesCategory
})
return { query, setQuery, category, setCategory, filtered }
}
// ProductSection owns the state (via the hook) — colocation is preserved
function ProductSection({ products }) {
const { query, setQuery, category, setCategory, filtered } =
useProductFilter(products)
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<select value={category} onChange={e => setCategory(e.target.value)}>
<option value="all">All</option>
<option value="electronics">Electronics</option>
</select>
<ProductGrid products={filtered} />
</>
)
}
// The hook can be reused in a different component:
function AdminProductPanel({ products }) {
const { query, setQuery, filtered } = useProductFilter(products)
// AdminProductPanel gets its own independent state — not shared with ProductSection
}A Mental Model for State Placement
Used in one component only → colocate inside that component.
Used in a parent and one child → colocate in the parent, pass as prop.
Used in two sibling components → lift to their shared parent.
Used in many components at different depths → use Context or a global store.
Ephemeral UI state (open/closed, hover, active tab) → almost always colocated, never global.
Shared entity state (current user, auth token, cart) → global store or Context.