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.

April 4, 20265 min read1 / 2

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:

  1. A component does something slow in its render body (heavy computation, large DOM diffs, synchronous parsing)
  2. 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."

TSX
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:

TSX
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.

TSX
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.

TSX
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:

TSX
// ✅ 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:

TSX
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.

Stop Re-renders with memo + useCallback + useMemo
1 exercise

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.