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