State Colocation & Lifting
Keep state as low as it can go, lift it only as high as it must — and feel the difference in re-renders.
Two forces pull on every piece of state. Lifting moves state up so more components can share it. Colocation moves state down, as close as possible to where it is used. Getting the balance right is one of the biggest levers you have over both performance and maintainability.
Colocate by default
State that only one component needs should live in that component. When state sits too high, every change re-renders the whole subtree beneath the owner — even branches that do not care. Push it down and a keystroke in a search box re-renders the search box, not the entire page.
// Before: the whole Page re-renders on every keystroke.
function Page() {
const [query, setQuery] = useState('');
return <><Header /><Search value={query} onChange={setQuery} /><BigList /></>;
}
// After: only Search re-renders; Header and BigList are untouched.
function Page() {
return <><Header /><Search /><BigList /></>;
}Lift only when truly shared
The moment two siblings must agree on a value, lift it to their nearest common parent — that is the single source of truth. But lift to the lowest common ancestor, not the root. Dumping everything in a top-level store or a giant App state is the most common cause of a sluggish, hard-to-follow app.
The middle path: composition
Before lifting state high and prop-drilling through the middle, remember you can often pass children instead. A component that receives its content as children does not re-render when the parent's state changes, because children was created by the parent above it. Composition can remove drilling and re-renders at once.
Example
import { useState, memo } from 'react';
// Heavy sibling we do NOT want re-rendering on every keystroke.
const ProductGrid = memo(function ProductGrid({ products }) {
return (
<ul>
{products.map((p) => <li key={p.id}>{p.name}</li>)}
</ul>
);
});
// Colocated: the query state lives inside the only component using it.
function SearchBox() {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter..."
/>
);
}
function Catalog({ products }) {
// Catalog does not own query, so typing in SearchBox
// never re-renders ProductGrid.
return (
<div>
<SearchBox />
<ProductGrid products={products} />
</div>
);
}When to use it
- Moving a tooltip's open/closed state from the page-level Redux store back into the <Tooltip> component eliminates unnecessary re-renders of the entire page on hover.
- Keeping a data-table's sort/filter state inside the <DataTable> component rather than a global store cuts boilerplate and narrows re-renders to only the table subtree.
- Lifting sibling-shared search input state from two local components up into their nearest common parent allows both a <SearchBar> and a <ResultsList> to stay in sync.
More examples
Colocate toggle state in component
Keeping open/closed state inside Collapsible means only this component re-renders on toggle, not its parent or any siblings.
// open state lives inside — only Collapsible re-renders on toggle
function Collapsible({ title, children }) {
const [open, setOpen] = React.useState(false);
return (
<div>
<button onClick={() => setOpen((o) => !o)}>
{title}
</button>
{open && <div className="content">{children}</div>}
</div>
);
}Lift shared state to common parent
Lifting query to ProductPage lets SearchBar and ProductList share one source of truth while both components remain lean and focused.
function ProductPage() {
const [query, setQuery] = React.useState('');
return (
<>
<SearchBar value={query} onChange={setQuery} />
<ProductList filter={query} />
</>
);
}
function SearchBar({ value, onChange }) {
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}
function ProductList({ filter }) {
const products = useProducts(filter);
return products.map((p) => <ProductCard key={p.id} product={p} />);
}Children slot limits re-render scope
Passing HeavyWidget as children means its owner is above FrequentUpdater, so frequent state changes there do not cause HeavyWidget to re-render.
// FrequentUpdater re-renders on every keystroke
// but HeavyWidget is passed as a slot — its owner is outside
function FrequentUpdater({ children }) {
const [count, setCount] = React.useState(0);
return (
<div onClick={() => setCount((c) => c + 1)}>
<p>Clicked {count} times</p>
{children}
</div>
);
}
// HeavyWidget is created by the caller, not inside FrequentUpdater
<FrequentUpdater>
<HeavyWidget />
</FrequentUpdater>
Discussion