React useMemo and useCallback: When They Pay Off
이 글의 핵심
useMemo and useCallback in React: reference stability and expensive work — when to use them, how to avoid premature optimization, and how to verify with Profiler.
Why Re-renders Happen
In React, a component re-renders when its state changes, its props change, or its parent re-renders. For most components, this is fine — renders are usually fast. The problem arises when:
- A computation inside the component is genuinely expensive (filtering 100k items, complex math)
- A new function or object reference is created on every render and passed to a memoized child — breaking that child’s memoization
useMemo and useCallback are tools to address these two specific cases. They are not a general-purpose performance boost.
useMemo: Memoize an Expensive Value
useMemo runs a function and caches the result. It only re-runs when the dependencies change:
import { useMemo, useState } from 'react';
type Item = { id: string; label: string; score: number };
function Leaderboard({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
// Without useMemo: runs on every render, even when only sortDir changes
// With useMemo: only re-runs when items or query changes
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(item => item.label.toLowerCase().includes(q));
}, [items, query]); // dependencies
// Second useMemo: only re-runs when filtered or sortDir changes
const sorted = useMemo(() => {
return [...filtered].sort((a, b) =>
sortDir === 'desc' ? b.score - a.score : a.score - b.score
);
}, [filtered, sortDir]);
return (
<section>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<button onClick={() => setSortDir(d => d === 'asc' ? 'desc' : 'asc')}>
Sort {sortDir === 'desc' ? '↑' : '↓'}
</button>
<ul>
{sorted.map(item => (
<li key={item.id}>{item.label} — {item.score}</li>
))}
</ul>
</section>
);
}
When the user toggles sort direction, filtered does not re-run — only sorted does. This pays off when items is large and the filter is slow.
useCallback: Stabilize a Function Reference
Every render creates new function objects. This matters when the function is passed to a child wrapped in React.memo — a new function reference breaks the memo comparison:
import { memo, useCallback, useState } from 'react';
// Wrapped in memo — only re-renders when props change by reference
const Row = memo(function Row({
id,
onSelect,
}: {
id: string;
onSelect: (id: string) => void;
}) {
console.log(`Row ${id} rendered`);
return <button onClick={() => onSelect(id)}>{id}</button>;
});
function Table({ ids }: { ids: string[] }) {
const [active, setActive] = useState<string | null>(null);
const [count, setCount] = useState(0);
// Without useCallback: new function every render → Row always re-renders
// With useCallback: same function reference → Row only re-renders if deps change
const handleSelect = useCallback((id: string) => {
setActive(id);
}, []); // no dependencies — never recreated
return (
<div>
<p>Active: {active}</p>
<button onClick={() => setCount(c => c + 1)}>
Increment ({count})
</button>
{ids.map(id => (
<Row key={id} id={id} onSelect={handleSelect} />
))}
</div>
);
}
When the user clicks “Increment”, count changes, Table re-renders — but handleSelect is the same reference, so Row does not re-render. Without useCallback, clicking increment would re-render all rows needlessly.
useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). It’s just syntax sugar for functions.
The Two Cases Where Memoization Helps
| Situation | Hook | When it pays off |
|---|---|---|
| Expensive pure computation | useMemo | When the computation is measurably slow (>1ms) and runs on renders that don’t change its inputs |
| Stable reference for memo’d child | useCallback or useMemo | When the child is wrapped in React.memo and the function/object is a prop |
| Creating a stable object for a Context value | useMemo | When the Context value is an object created inline and consumed by many components |
Context Value Stability
A common source of unnecessary re-renders: creating a new object in the Context Provider on every render:
// BAD: new object every render → all consumers re-render
function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<'light' | 'dark'>('light');
return (
<AppContext.Provider value={{ user, setUser, theme, setTheme }}>
{children}
</AppContext.Provider>
);
}
// GOOD: stable object reference — consumers only re-render when user or theme changes
function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const value = useMemo(() => ({ user, setUser, theme, setTheme }), [user, theme]);
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
Or split the context into fast-changing and slow-changing parts:
// Fast-changing: theme, locale
const UIContext = createContext<UIState>(...);
// Slow-changing: auth state, user profile
const AuthContext = createContext<AuthState>(...);
// Components that only care about theme don't re-render on auth changes
Measuring with React DevTools Profiler
Never add useMemo/useCallback without measuring first. Use the React DevTools Profiler:
- Open browser DevTools → React tab → Profiler
- Click Record, interact with your component, click Stop
- Look for components with long render times (orange/red bars)
- Check the “Why did this render?” panel — if a component re-renders with “hook changed” on every parent render,
useCallbackmight help
Typical findings:
- Most components render in < 1ms — memoization adds more overhead than it saves
- A few components with large lists or complex computations benefit significantly
- Leaf components wrapped in
memobenefit from stable callback references
When NOT to Use These Hooks
Cheap computations: a simple arithmetic expression, array slice, string concatenation — memoizing these is slower than just recomputing. The useMemo call itself has overhead.
Components that always re-render anyway: if a component renders when its state changes, and that state is the input to the useMemo, you’re not saving anything.
Non-memo children: useCallback only helps when the child uses React.memo (or a dependency array like useEffect). If the child always re-renders regardless, the stable reference doesn’t matter.
// No benefit: Child doesn't use memo, so it re-renders on every parent render anyway
function Parent() {
const fn = useCallback(() => doSomething(), []); // unnecessary
return <Child onAction={fn} />; // Child will re-render anyway
}
// Benefit: Child uses memo — the stable reference prevents unnecessary renders
const Child = memo(({ onAction }) => <button onClick={onAction}>Click</button>);
Dependency Array Gotchas
Stale closure: forgetting a dependency means the memoized value or callback captures an old value:
// BUG: userId is used inside but not listed as a dependency
const fetchUser = useCallback(async () => {
const data = await api.get(`/users/${userId}`); // always uses initial userId
setUser(data);
}, []); // should be [userId]
// CORRECT
const fetchUser = useCallback(async () => {
const data = await api.get(`/users/${userId}`);
setUser(data);
}, [userId]); // re-created when userId changes
Use the eslint-plugin-react-hooks rule react-hooks/exhaustive-deps to catch missing dependencies automatically.
Object/array in dependencies: objects are compared by reference. If a parent creates options = { limit: 10 } inline, it’s a new object every render — which defeats the memoization:
// BAD: options is a new object every render → useMemo always re-runs
const results = useMemo(() => filter(data, options), [data, options]);
// GOOD: either memoize options separately, or list specific values
const results = useMemo(() => filter(data, { limit: 10 }), [data]);
// Or:
const { limit, offset } = options;
const results = useMemo(() => filter(data, { limit, offset }), [data, limit, offset]);
Practical Checklist
Before adding useMemo or useCallback, answer:
- Did you measure? Open the Profiler and confirm the component renders unnecessarily or slowly.
- Is the computation expensive? Filtering 10k items: yes. Adding two numbers: no.
- Is the child memoized?
useCallbackonly helps if the child is wrapped inReact.memo. - Are all dependencies listed? Run
react-hooks/exhaustive-depsand fix all warnings. - Does it actually help? Re-measure with the Profiler after adding the hook.
Key Takeaways
useMemocaches an expensive computed value — re-runs only when dependencies changeuseCallbackcaches a function reference — prevents re-creating it on every render- Both hooks help in two specific cases: expensive computation, or stable reference for
React.memochildren useCallback(fn, deps)is syntactic sugar foruseMemo(() => fn, deps)- Measure first with the Profiler — don’t add these hooks speculatively
- Context providers should wrap the value object in
useMemoto prevent all consumers from re-rendering on unrelated state changes - Missing dependencies cause stale closures; use
eslint-plugin-react-hooksto catch them
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. useMemo and useCallback in React: reference stability and expensive work — when to use them, how to avoid premature.
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
- JavaScript 비동기 디버깅 실전 사례 | Promise 체인 에러 추적하기
- JavaScript 비동기 프로그래밍 | Promise, async/await 완벽 정리
- JavaScript 디자인 패턴 | 싱글톤, 팩토리, 옵저버 패턴
Keywords Covered in This Article (Related Search Terms)
This article covers React, useMemo, useCallback, Performance, Rendering, Memoization, Hooks.