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
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. useMemo and useCallback in React: reference stability and expensive work — when to use them, how to avoid premature opti… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- JavaScript 비동기 디버깅 실전 사례 | Promise 체인 에러 추적하기
- JavaScript 비동기 프로그래밍 | Promise, async/await 완벽 정리
- JavaScript 디자인 패턴 | 싱글톤, 팩토리, 옵저버 패턴
이 글에서 다루는 키워드 (관련 검색어)
React, useMemo, useCallback, Performance, Rendering, Memoization, Hooks 등으로 검색하시면 이 글이 도움이 됩니다.