[2026] React useMemo and useCallback: When to Use | Rendering Optimization Complete Guide
이 글의 핵심
useMemo and useCallback in React are tools for reference equality and expensive computations. Covers principles, when to use, avoiding over-optimization, and verification with Profiler.
Introduction
In React 18/19, function components re-execute frequently based on props, state, and parent re-renders. useMemo and useCallback are tools that reuse previous results to reduce unnecessary computations and reference changes. When to use React useMemo and useCallback is first divided by “is it expensive computation, is reference stability needed?” However, they also have costs, so overuse can actually slow things down.
This article contains criteria for determining when memoization is beneficial and the process of verification with Profiler. For async flow, see async guide, for debugging see async debugging case.
Why do useMemo and useCallback exist?
Function components re-execute every render, so object and function references created inside are also newly created by default. These references affect React.memo children, useEffect dependencies, and external hook stability, so useMemo and useCallback are tools to reuse previous references only when needed.
Production Cautions
- Applying to all layers without measurement can increase memory and comparison costs with no noticeable improvement. It’s recommended to check bottlenecks with React DevTools Profiler first.
- Incorrect dependency arrays lead to “memoized but updates are weird” or “shouldn’t change but does”. Use ESLint
exhaustive-depswith team rules. - Parts where data can be lifted to server components reduce client memoization burden itself. Review boundary design first.
Table of Contents
- Concept Explanation
- Practical Implementation (Step-by-step Code)
- Advanced Usage
- Performance Comparison
- Real-world Cases
- Troubleshooting
- Conclusion
Concept Explanation
Re-rendering Basics
- Re-render: When parent renders, children also re-render by default (unless conditional).
- Reference Equality: Object, function, and array literals become new references every render. Can lead to unnecessary changes in
React.memowrapped children oruseEffectdependency arrays.
useMemo and useCallback
- useMemo: Recalculates values (objects, arrays, computation results) only when dependencies change.
- useCallback: Maintains function references only when dependencies change. Essentially syntactic sugar for
useMemo(() => fn, deps).
When to Use?
useMemo Use Cases:
- Expensive computations: Array filtering, sorting, complex operations
- Reference stabilization: When passing objects or arrays as props
- Dependency arrays: When used as
useEffectdependenciesuseCallbackUse Cases: React.memochildren: Passing callbacks to memoized children- Dependency arrays: When used as
useEffectdependencies - External hooks: External libraries requiring reference stability
When NOT to Use?
- Lightweight operations: Simple calculations may cost more to memoize
- Without measurement: Before checking bottlenecks with Profiler
- All functions: Excessive memoization only increases code complexity
Practical Implementation (Step-by-step Code)
1) Expensive List Filtering — useMemo
Problem: Re-filtering every render The following is a detailed implementation code using TSX. Import necessary modules, define classes to encapsulate data and functionality, and process data with loops. Understand the role of each part as you examine the code.
import { useState } from "react";
type Item = { id: string; label: string; score: number };
export function LeaderboardBad({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const q = query.trim().toLowerCase();
const visible = !q
? items
: items.filter((it) => it.label.toLowerCase().includes(q));
return (
<section>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{visible.map((it) => (
<li key={it.id}>
{it.label} — {it.score}
</li>
))}
</ul>
</section>
);
}
Issues:
- Re-filtering every parent re-render
- Performance degradation with thousands of items
Solution: Cache with
useMemoThe following is a detailed implementation code using TSX. Import necessary modules, define classes to encapsulate data and functionality, process data with loops, and perform branching with conditionals. Understand the role of each part as you examine the code.
import { useMemo, useState } from "react";
type Item = { id: string; label: string; score: number };
export function Leaderboard({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const visible = useMemo(() => {
console.log("Filtering executed");
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter((it) => it.label.toLowerCase().includes(q));
}, [items, query]);
return (
<section>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{visible.map((it) => (
<li key={it.id}>
{it.label} — {it.score}
</li>
))}
</ul>
</section>
);
}
Effect:
- Filtering executes only when
itemsandquerychange - Reuses cached results on parent re-render
2) Stabilize Child Callbacks — useCallback + memo
Problem: New function created every render The following is a detailed implementation code using TSX. Import necessary modules and process data with loops. Understand the role of each part as you examine the code.
import { memo, useState } from "react";
const Row = memo(function Row({
id,
onSelect,
}: {
id: string;
onSelect: (id: string) => void;
}) {
console.log(`Row ${id} render`);
return <button onClick={() => onSelect(id)}>{id}</button>;
});
export function TableBad({ ids }: { ids: string[] }) {
const [active, setActive] = useState<string | null>(null);
const handleSelect = (id: string) => {
setActive(id);
};
return (
<div>
<p>active: {active}</p>
{ids.map((id) => (
<Row key={id} id={id} onSelect={handleSelect} />
))}
</div>
);
}
Issues:
handleSelectis a new function every render- All
Rowcomponents re-render despitememoSolution: Stabilize withuseCallbackThe following is a detailed implementation code using TSX. Import necessary modules and process data with loops. Understand the role of each part as you examine the code.
import { memo, useCallback, useState } from "react";
const Row = memo(function Row({
id,
onSelect,
}: {
id: string;
onSelect: (id: string) => void;
}) {
console.log(`Row ${id} render`);
return <button onClick={() => onSelect(id)}>{id}</button>;
});
export function Table({ ids }: { ids: string[] }) {
const [active, setActive] = useState<string | null>(null);
const handleSelect = useCallback((id: string) => {
setActive(id);
}, []);
return (
<div>
<p>active: {active}</p>
{ids.map((id) => (
<Row key={id} id={id} onSelect={handleSelect} />
))}
</div>
);
}
Effect:
handleSelectreference remains stable- Only the clicked
Rowre-renders
3) Object Reference Stabilization — useMemo
Problem: Object recreated every render
export function ConfigBad({ userId }: { userId: string }) {
const config = { userId, theme: "dark" };
useEffect(() => {
console.log("Config changed");
// API call
}, [config]); // Re-runs every render!
return <div>User: {userId}</div>;
}
Solution: Stabilize with useMemo
export function Config({ userId }: { userId: string }) {
const config = useMemo(
() => ({ userId, theme: "dark" }),
[userId]
);
useEffect(() => {
console.log("Config changed");
// API call
}, [config]); // Runs only when userId changes
return <div>User: {userId}</div>;
}
Advanced Usage
1) Combining with React.memo
import { memo, useCallback, useState } from "react";
interface TodoItemProps {
id: string;
text: string;
onToggle: (id: string) => void;
}
const TodoItem = memo(function TodoItem({ id, text, onToggle }: TodoItemProps) {
console.log(`TodoItem ${id} render`);
return (
<li>
<input type="checkbox" onChange={() => onToggle(id)} />
{text}
</li>
);
});
export function TodoList() {
const [todos, setTodos] = useState([
{ id: "1", text: "Learn React", done: false },
{ id: "2", text: "Build app", done: false },
]);
const handleToggle = useCallback((id: string) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
}, []);
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} id={todo.id} text={todo.text} onToggle={handleToggle} />
))}
</ul>
);
}
2) Dependency Array Best Practices
Bad: Missing dependencies
const handleClick = useCallback(() => {
console.log(count); // Stale closure!
}, []); // Missing count
Good: Include all dependencies
const handleClick = useCallback(() => {
console.log(count);
}, [count]); // Correct
Better: Use functional updates
const handleIncrement = useCallback(() => {
setCount((prev) => prev + 1); // No dependency needed
}, []);
Performance Comparison
Measuring with React DevTools Profiler
- Install React DevTools extension
- Open DevTools → Profiler tab
- Click record button
- Perform actions
- Stop recording
- Analyze flame graph What to Look For:
- Components with long render times
- Components that re-render unnecessarily
- Render count vs actual changes
When Memoization Helps
Scenario 1: Large Lists
- 1000+ items
- Expensive filtering/sorting
- Result: 50-90% render time reduction Scenario 2: Complex Calculations
- Heavy computations (>10ms)
- Runs on every render
- Result: Noticeable UI responsiveness Scenario 3: Deep Component Trees
- Many nested components
- Props rarely change
- Result: Prevents cascade re-renders
When Memoization Hurts
Scenario 1: Simple Components
- Render time < 1ms
- Memoization overhead > render cost
- Result: Slower performance Scenario 2: Frequently Changing Dependencies
- Dependencies change every render
- Cache never reused
- Result: Wasted memory Scenario 3: Over-memoization
- Every function wrapped in useCallback
- Every value wrapped in useMemo
- Result: Code complexity, no benefit
Real-world Cases
Case 1: Data Table with Filtering
import { useMemo, useState } from "react";
interface User {
id: string;
name: string;
email: string;
role: string;
}
export function UserTable({ users }: { users: User[] }) {
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<string | null>(null);
const filteredUsers = useMemo(() => {
console.log("Filtering users");
let result = users;
if (search) {
const query = search.toLowerCase();
result = result.filter(
(u) =>
u.name.toLowerCase().includes(query) ||
u.email.toLowerCase().includes(query)
);
}
if (roleFilter) {
result = result.filter((u) => u.role === roleFilter);
}
return result;
}, [users, search, roleFilter]);
return (
<div>
<input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select onChange={(e) => setRoleFilter(e.target.value || null)}>
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<table>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.role}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Case 2: Form with Validation
import { useCallback, useMemo, useState } from "react";
export function SignupForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const emailError = useMemo(() => {
if (!email) return null;
return email.includes("@") ? null : "Invalid email";
}, [email]);
const passwordError = useMemo(() => {
if (!password) return null;
return password.length >= 8 ? null : "Password too short";
}, [password]);
const isValid = useMemo(
() => !emailError && !passwordError && email && password,
[emailError, passwordError, email, password]
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (isValid) {
console.log("Submit:", { email, password });
}
},
[isValid, email, password]
);
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{emailError && <span>{emailError}</span>}
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{passwordError && <span>{passwordError}</span>}
<button type="submit" disabled={!isValid}>
Sign Up
</button>
</form>
);
}
Troubleshooting
Problem 1: Stale Closures
Symptom: Callback uses old state values
// Bad
const handleClick = useCallback(() => {
console.log(count); // Always 0!
}, []);
Solution: Include dependencies or use functional updates
// Good: Include dependency
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
// Better: Functional update
const handleIncrement = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
Problem 2: Dependency Array Warnings
Symptom: ESLint warns about missing dependencies
Solution: Follow ESLint suggestions or use useRef for non-reactive values
// If value shouldn't trigger re-run
const timeoutRef = useRef<number>();
const handleDebounce = useCallback(() => {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
// Do something
}, 300);
}, []); // No warning
Problem 3: Over-optimization
Symptom: Code is complex but no performance gain Solution: Remove unnecessary memoization
// Unnecessary
const sum = useMemo(() => a + b, [a, b]);
// Just do it
const sum = a + b;
Conclusion
Key Takeaways
- Measure First: Use React DevTools Profiler before optimizing
- useMemo for Values: Expensive computations and reference stability
- useCallback for Functions: When passing to memoized children
- Don’t Over-optimize: Simple operations don’t need memoization
- Correct Dependencies: Always include all dependencies
Decision Tree
Should I use useMemo/useCallback?
├─ Is it expensive (>10ms)?
│ ├─ Yes → Use useMemo
│ └─ No → Continue
├─ Is it passed to React.memo child?
│ ├─ Yes → Use useCallback/useMemo
│ └─ No → Continue
├─ Is it in useEffect dependencies?
│ ├─ Yes → Consider useMemo/useCallback
│ └─ No → Don't memoize
Best Practices
- Start without memoization
- Profile to find bottlenecks
- Add memoization where it helps
- Keep dependency arrays correct
- Document why you memoized
Related Articles
Keywords
React, useMemo, useCallback, Optimization, Rendering, Memoization, Performance, Hooks