Avoiding Unnecessary Rerenders in React
How React decides to rerender, where reference equality matters, and the targeted use of memo, useMemo, and useCallback when profiling confirms a problem.
The instinct when a React app feels slow is to reach for React.memo, useMemo, and useCallback. Wrap everything, memoize all the things, and watch the problem disappear. That instinct is usually wrong. Premature memoization adds complexity, makes the code harder to read, and sometimes makes performance worse. Profile first. Optimize only what you can measure.
How React Decides to Rerender
React's reconciler rerenders a component when one of three things happens: its state changes, its context value changes, or its parent rerenders. That last one catches people off guard. When a parent rerenders, every child rerenders by default, regardless of whether the props changed.
This is not a bug. For most trees, the diff is cheap and the DOM doesn't change. React's virtual DOM is fast. The problem only shows up when a component is genuinely expensive to render and its parent rerenders frequently.
Reference Equality Is the Core Issue
JavaScript compares objects and arrays by reference, not by value. This matters because React uses Object.is to compare prop values when deciding whether a memoized component needs to rerender.
function Parent() {
const [count, setCount] = useState(0)
// A new object is created on every render
const config = { theme: 'dark', size: 'lg' }
return <Child config={config} onReset={() => setCount(0)} />
}
Even if Child is wrapped in React.memo, it will rerender on every Parent render. The config object and the inline arrow function are new references each time. Object.is({ theme: 'dark' }, { theme: 'dark' }) returns false.
React.memo
React.memo wraps a component and skips rerendering if the props haven't changed (by reference). It's the right tool when a component is slow to render and its parent rerenders often with the same prop values.
type ButtonProps = {
label: string
onClick: () => void
}
const SubmitButton = React.memo(function SubmitButton({ label, onClick }: ButtonProps) {
console.log('SubmitButton rendered')
return <button onClick={onClick}>{label}</button>
})
This only helps if the onClick reference is stable. Pass a new function on every render and React.memo buys you nothing.
useCallback
useCallback returns a memoized function. The function is recreated only when its dependencies change.
function Form() {
const [value, setValue] = useState('')
const handleSubmit = useCallback(() => {
console.log('submitted:', value)
}, [value])
return <SubmitButton label="Submit" onClick={handleSubmit} />
}
Now SubmitButton only rerenders when value changes, because handleSubmit keeps the same reference otherwise. Without useCallback, every Form render produces a new function and the memo on SubmitButton never fires.
useMemo
useMemo memoizes a computed value, not a function. Use it when a calculation is expensive and its inputs don't change every render.
function ProductList({ products, filterText }: Props) {
const filtered = useMemo(
() => products.filter((p) => p.name.toLowerCase().includes(filterText.toLowerCase())),
[products, filterText]
)
return (
<ul>
{filtered.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
For a list of 20 items, this is wasted effort. For 50,000 items with a complex filter, it's worth it. The only way to know which side you're on is to measure.
The key Prop Gotcha
The key prop is how React identifies list items. If the key changes, React destroys the old component instance and mounts a new one. That means all state is reset and all effects rerun. It's expensive, and it happens silently.
// Fragile: index keys make state stick to positions, so reordering/inserting can show the wrong item state
{items.map((item, index) => (
<Row key={index} item={item} />
))}
// Correct: stable identity key
{items.map((item) => (
<Row key={item.id} item={item} />
))}
Using array index as a key is fine for static lists that never reorder or change length. For anything dynamic, use a stable ID from the data.
Profile Before You Optimize
React DevTools has a Profiler tab. Record a slow interaction, look at the flame graph, and find components that take real time to render. That tells you where memoization is worth the cost. Without profiling, you're guessing.
The cost of useMemo and useCallback is not zero. Each one allocates memory, runs on every render to check dependencies, and makes the code harder to follow. Wrapping a fast component in React.memo adds overhead that can exceed whatever you were saving.
The path that works: render first, profile when it feels slow, then apply memoization only where the flame graph shows a genuine bottleneck. Most of the time the problem is a single expensive component or a missed stable key, not a missing useCallback on every handler in the tree.
Newsletter
A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.