[2026] React useMemo and useCallback: When to Use | Rendering Optimization Complete Guide

[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-deps with team rules.
  • Parts where data can be lifted to server components reduce client memoization burden itself. Review boundary design first.

Table of Contents

  1. Concept Explanation
  2. Practical Implementation (Step-by-step Code)
  3. Advanced Usage
  4. Performance Comparison
  5. Real-world Cases
  6. Troubleshooting
  7. 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.memo wrapped children or useEffect dependency 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:

  1. Expensive computations: Array filtering, sorting, complex operations
  2. Reference stabilization: When passing objects or arrays as props
  3. Dependency arrays: When used as useEffect dependencies useCallback Use Cases:
  4. React.memo children: Passing callbacks to memoized children
  5. Dependency arrays: When used as useEffect dependencies
  6. 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 useMemo The 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 items and query change
  • 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:

  • handleSelect is a new function every render
  • All Row components re-render despite memo Solution: Stabilize with useCallback 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, 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:

  • handleSelect reference remains stable
  • Only the clicked Row re-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

  1. Install React DevTools extension
  2. Open DevTools → Profiler tab
  3. Click record button
  4. Perform actions
  5. Stop recording
  6. 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

  1. Measure First: Use React DevTools Profiler before optimizing
  2. useMemo for Values: Expensive computations and reference stability
  3. useCallback for Functions: When passing to memoized children
  4. Don’t Over-optimize: Simple operations don’t need memoization
  5. 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


Keywords

React, useMemo, useCallback, Optimization, Rendering, Memoization, Performance, Hooks

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3