Stop unnecessary re-renders in React: memo, useMemo, useCallback - and when not to
Your app re-renders too much and you're not sure where. How to find the actual hot spots with the React Profiler, fix them with composition before memoization, and what the React Compiler changes.
First, the reassurance: re-renders are usually fine. React re-rendering a component does not mean the DOM changed - reconciliation throws away most of that work cheaply. The problem is re-renders that are both frequent AND expensive: a 500-row table re-rendering on every keystroke in an unrelated search box. Fix those; ignore the rest.
Step 1: measure before touching anything
Open React DevTools → Profiler, record the slow interaction, and look at the flame graph. Enable 'Highlight updates when components render' to see the blast radius live. The question is never 'how do I stop re-renders' - it's 'which specific component is slow, and why is it rendering when nothing it shows has changed?'
Step 2: composition fixes beat memoization
// ❌ Typing re-renders the ENTIRE page because state lives too high
function Page() {
const [query, setQuery] = useState('');
return (<>
<SearchBox value={query} onChange={setQuery} />
<ExpensiveTable rows={rows} />
</>);
}
// ✅ Move the state down - the table no longer cares
function Page() {
return (<>
<Search /> {/* state lives inside */}
<ExpensiveTable rows={rows} />
</>);
}The two structural moves - push state down into the component that uses it, or lift expensive children up as a `children` prop so they're created by a parent that doesn't re-render - eliminate most 'everything re-renders' complaints with zero memoization. React skips reconciling children whose element identity hasn't changed.
Step 3: memoize the survivors
const Row = memo(function Row({ item, onSelect }: RowProps) {
return <tr onClick={() => onSelect(item.id)}>...</tr>;
});
// memo is defeated by unstable props - stabilize them:
const onSelect = useCallback((id: string) => setSelected(id), []);
const sorted = useMemo(() => [...rows].sort(byDate), [rows]);memo compares props by reference, so a fresh inline callback or a .sort() result created each render silently disables it. That's the real job of useCallback and useMemo: keeping references stable so memo works. Memoizing values nobody compares is pure overhead - every useMemo costs a comparison and cache space on every render.
The React Compiler changes the default
The React Compiler (stable since late 2025) auto-memoizes components and values at build time, making most hand-written memo/useCallback/useMemo redundant in codebases that follow the Rules of React. If you're starting fresh, enable it and write plain code. Structural fixes still matter - the compiler can't move your state down a level - but the memoization boilerplate era is ending.
Checklist for a janky interaction
- Profile first - name the slow component before changing code.
- State too high? Push it down. Expensive child re-created? Pass it as children.
- Lists: stable keys plus virtualization (TanStack Virtual) beat memoizing 1,000 rows.
- Context splitting: a context whose value changes often shouldn't also carry values that rarely change - every consumer re-renders on any change.
- Only then: memo the component, and stabilize every prop it receives.
Written by Appesto Engineering.