React applications start fast. They get slow when they grow. The difference between a 10-component app and a 100-component app is usually not the number of components — it's whether the developer understood when React re-renders.
This post covers the patterns I reach for when a React codebase starts fighting back.
The Re-render Problem
Every state change in React triggers a re-render of the component and all of its children. This is the default. It's correct. But it becomes a problem when you have a parent component holding state that changes frequently, and deeply nested children that don't depend on that state at all.
// Every time `count` changes, ExpensiveChart re-renders
// even though it doesn't use count at all
function Dashboard() {
const [count, setCount] = useState(0);
return (
<div>
<Counter count={count} onIncrement={() => setCount(c => c + 1)} />
<ExpensiveChart data={chartData} />
</div>
);
}
The fix isn't always React.memo. The fix is lifting state down.
Lift State Down Before You Memo
The most underused optimization in React isn't useMemo — it's putting state as close as possible to where it's used. Extract the counter into its own component:
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
function Dashboard() {
return (
<div>
<Counter />
<ExpensiveChart data={chartData} />
</div>
);
}
Now ExpensiveChart never re-renders when the count changes. No memo, no useMemo, no useCallback needed. Just better component structure.
When React.memo Actually Helps
React.memo prevents a component from re-rendering when its props haven't changed (by reference). It's worth adding when:
- The component renders slowly (big lists, heavy computations)
- The parent re-renders frequently
- The props are stable
const ExpensiveChart = React.memo(function ExpensiveChart({ data }: { data: ChartData }) {
// Only re-renders when `data` reference changes
return <canvas ref={canvasRef} />;
});
The trap: React.memo does a shallow comparison. Pass a new object literal and you've wasted the memo.
// This breaks memo — new object every render
<ExpensiveChart data={{ values: chartValues }} />
// This works — stable reference
const chartData = useMemo(() => ({ values: chartValues }), [chartValues]);
<ExpensiveChart data={chartData} />
useCallback for Stable Function References
Functions defined inside a component are recreated every render. This breaks React.memo on children that receive them as props.
// Bad: new function reference every render
<Button onClick={() => handleSubmit(id)} />
// Good: stable reference
const handleClick = useCallback(() => handleSubmit(id), [id, handleSubmit]);
<Button onClick={handleClick} />
The rule: add useCallback when you're passing a function to a memoised child. Skip it otherwise — it adds overhead.
The Custom Hook Pattern
Custom hooks are how you share stateful logic across components without prop drilling or context. The pattern is simple: extract any useState + useEffect combination that appears in more than one place.
function useWindowSize() {
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight });
useEffect(() => {
const handler = () =>
setSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return size;
}
// Use anywhere — no duplication
function Header() {
const { width } = useWindowSize();
return <nav className={width < 768 ? 'mobile' : 'desktop'} />;
}
The Compound Component Pattern
When a component has too many boolean props controlling its internals, it's time for compound components. This pattern lets callers compose the internals themselves.
// Before: prop soup
<Modal isOpen title="Confirm" showFooter showHeader onClose={close}>
<p>Are you sure?</p>
</Modal>
// After: compound components
<Modal isOpen onClose={close}>
<Modal.Header>Confirm</Modal.Header>
<Modal.Body>
<p>Are you sure?</p>
</Modal.Body>
<Modal.Footer>
<Button onClick={close}>Cancel</Button>
<Button variant="danger" onClick={confirm}>Delete</Button>
</Modal.Footer>
</Modal>
Implementation uses context to share the isOpen/onClose state between siblings without prop drilling:
const ModalContext = createContext<ModalContextValue | null>(null);
function Modal({ isOpen, onClose, children }: ModalProps) {
return (
<ModalContext.Provider value={{ isOpen, onClose }}>
{isOpen && <div className="modal-backdrop">{children}</div>}
</ModalContext.Provider>
);
}
Modal.Header = function ModalHeader({ children }: { children: ReactNode }) {
const { onClose } = useContext(ModalContext)!;
return (
<header>
<h2>{children}</h2>
<button onClick={onClose}>×</button>
</header>
);
};
Virtual Lists for Long Sequences
Rendering 1,000 DOM nodes is slow. The fix is a virtual list: only render what's visible in the viewport, swap items in and out as the user scrolls.
Libraries like @tanstack/react-virtual make this straightforward:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(vItem => (
<div
key={vItem.key}
style={{ position: 'absolute', top: vItem.start, width: '100%' }}
>
<ItemRow item={items[vItem.index]} />
</div>
))}
</div>
</div>
);
}
Key Takeaways
- Lift state down before adding any memoisation.
React.memoonly helps when props are stable — useuseMemo/useCallbackto make them stable.- Custom hooks are the right abstraction for shared stateful logic.
- Compound components replace boolean-prop overload.
- Virtualise lists beyond ~100 items.
Profile first with React DevTools Profiler. Optimise what's actually slow.