memo, useCallback, and useMemo: When They Help and When They Don't
React re-renders are cheap — until they're not. Here's exactly when memo, useCallback, and useMemo actually help, and when they make things worse.
React's performance model is straightforward: when state changes, React re-renders the component that owns it plus all of its descendants. Most of the time, this is fast enough that you will never notice. The tools covered here — memo, useCallback, useMemo — are for when you do notice.
The golden rule: Wait until you have a measured performance problem before reaching for these. They add complexity, introduce subtle bugs, and often make code harder to follow — for zero gain when the renders are already fast.
Why Re-renders Are Usually Fine
Rendering a React component means calling a function. For most components, that function runs in under a millisecond. Even re-rendering a tree of 50 components on a keystroke is typically imperceptible.
The two situations where re-renders become expensive:
- A component does something slow in its render body (heavy computation, large DOM diffs, synchronous parsing)
- A component re-renders very frequently (every keystroke, every mouse move, every animation frame)
React gives you three tools to limit re-renders once you've confirmed they're a problem.
memo: Skip a Re-render When Props Haven't Changed
memo wraps a component and tells React: "only re-render this if its props have changed."
import { memo } from 'react';
const MarkdownPreview = memo(function MarkdownPreview({ text, theme }) {
// Expensive: parsing markdown + applying theme on every render
const html = marked.parse(text);
return <div style={{ color: theme }} dangerouslySetInnerHTML={{ __html: html }} />;
});Without memo: every time the parent re-renders (for any reason), MarkdownPreview re-renders too, even if text and theme haven't changed.
With memo: React does a shallow equality check on the props. If they're the same references, it skips the re-render entirely.
The catch: shallow equality. memo compares props with ===. For primitives (string, number, boolean), this works perfectly. For objects and functions, it almost always fails:
function Parent() {
const [theme, setTheme] = useState('dark');
// ❌ New object created on every render — memo() sees a "new" prop every time
const options = { theme, fontSize: 16 };
// ❌ New function created on every render — same problem
const handleUpdate = (text) => processText(text, theme);
return <MarkdownPreview options={options} onUpdate={handleUpdate} />;
}Even with memo, MarkdownPreview re-renders on every parent render because options and handleUpdate are new object/function references each time.
useCallback: Stable Function References
useCallback memoizes a function — it returns the same function reference across renders as long as its dependencies haven't changed.
import { useState, useCallback } from 'react';
function Parent() {
const [theme, setTheme] = useState('dark');
// ✅ Same function reference across renders unless `theme` changes
const handleUpdate = useCallback(
(text) => processText(text, theme),
[theme] // dependency array — same as useEffect
);
return <MarkdownPreview onUpdate={handleUpdate} />;
}Now memo can do its job: handleUpdate only gets a new reference when theme changes, so MarkdownPreview only re-renders when theme actually changes.
The dependency array works exactly like useEffect: list everything the function reads from the component scope. If you forget a dependency, the function silently closes over a stale value.
useMemo: Cache an Expensive Computed Value
useMemo memoizes a value (not a function). Use it when computing a value is expensive and you'd rather cache the result than recompute on every render.
import { useState, useMemo } from 'react';
function SearchResults({ items, query }) {
// ✅ Only re-filters when items or query changes
const filtered = useMemo(
() => items.filter(item => item.name.includes(query)),
[items, query]
);
return <ul>{filtered.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}useMemo also solves the object-prop problem without useCallback:
// ✅ options is only recreated when theme changes
const options = useMemo(() => ({ theme, fontSize: 16 }), [theme]);Putting It Together: The Full Pattern
Here's the complete pattern for a component that genuinely benefits from memoization:
import { useState, useCallback, useMemo, memo } from 'react';
import { marked } from 'marked';
// Step 1: Wrap the expensive component with memo
const MarkdownPreview = memo(function MarkdownPreview({ render, options }) {
return (
<div
style={{ color: options.theme === 'dark' ? '#fff' : '#000' }}
dangerouslySetInnerHTML={{ __html: render(options.text) }}
/>
);
});
// Step 2: In the parent, stabilize the function and object props
function Editor() {
const [text, setText] = useState('# Hello');
const [theme, setTheme] = useState('dark');
const [unrelated, setUnrelated] = useState(0); // changes don't affect MarkdownPreview
// ✅ Stable function — memo()'s equality check passes
const render = useCallback((src) => marked.parse(src), []);
// ✅ Stable object — only changes when text or theme change
const options = useMemo(() => ({ text, theme }), [text, theme]);
return (
<>
<button onClick={() => setUnrelated(n => n + 1)}>
Update unrelated state ({unrelated})
</button>
<textarea value={text} onChange={e => setText(e.target.value)} />
<MarkdownPreview render={render} options={options} />
</>
);
}Now MarkdownPreview only re-renders when text or theme actually changes. Clicking the "Update unrelated state" button re-renders Editor but skips MarkdownPreview.
The Traps
Overusing useCallback on cheap functions. Memoizing a function that runs in 0.01ms saves less time than the overhead of useCallback itself. Only apply it when the function is passed to a memo-wrapped component as a prop.
Missing dependencies. A stale closure in a useCallback with a wrong dependency array produces bugs that are notoriously hard to debug — the function appears correct but operates on outdated state.
Memoizing things that always change. useMemo with [Math.random()] as a dependency never actually caches anything.
Skipping the profiler. React DevTools has a Profiler tab that shows exactly which components re-render and why. Use it before applying any of these tools — you may find that the bottleneck is somewhere completely different.
Practice what you just read.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.