React useMemo and useCallback: When They Pay Off | Render Optimization

React useMemo and useCallback: When They Pay Off | Render Optimization

이 글의 핵심

Use useMemo and useCallback for stable references and heavy pure work—establish proof with Profiler before sprinkling them everywhere.

Introduction

In React 18/19, function components re-run on props, state, and parent re-renders. useMemo and useCallback reuse previous results to cut unnecessary work and reference churn. Deciding when to use React useMemo and useCallback starts with: “Is this expensive pure work?” vs “Do I need reference stability?”—but both hooks have their own cost, so overuse can slow things down.

This post covers criteria for when memoization helps and how to validate with the Profiler. For async flows see the async guide; for debugging see async debugging case study.


Table of contents

  1. Concepts
  2. Step-by-step examples
  3. Advanced usage
  4. Performance comparison
  5. Real-world cases
  6. Troubleshooting
  7. Wrap-up

Concepts

  • Re-render: by default, when the parent renders, children render again too (unless conditional).
  • Reference identity: object, function, and array literals get new references every render—bad for React.memo children or useEffect dependency arrays.
  • useMemo: recompute a value only when dependencies change.
  • useCallback: keep a function reference stable unless dependencies change—syntactic sugar over useMemo(() => fn, deps).

Step-by-step examples

1) Heavy list filtering — useMemo

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(() => {
    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>
  );
}

With thousands of items or a heavy filter, gains are easier to measure.

2) Stabilize child callbacks — useCallback + memo

import { memo, useCallback, useState } from "react";

const Row = memo(function Row({
  id,
  onSelect,
}: {
  id: string;
  onSelect: (id: string) => void;
}) {
  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>
  );
}

If handleSelect is recreated every render, Row’s memo is weakened.

3) Split Context values — avoid one big object in Provider

Putting { value: { a, b } } with a new object every render refreshes all consumers. Split fast-changing vs slow-changing values or wrap value in useMemo.


Advanced: child memo and custom compare

  • memo(Component, arePropsEqual): use custom comparison only when shallow compare is insufficient—too much custom logic hurts debugging.
  • React Compiler (ecosystem): automatic memoization may reduce manual hooks—until then, measure first.
  • Server Components: fetching on the server reduces client memoization load—boundary design comes first.

Performance: when it pays off

SituationuseMemouseCallback
Heavy pure computationcandidateN/A
Stable object/array propscandidatesometimes both
Handler passed to memo childrensmall direct gainpairs with child memo
Already cheap workmay add overhead onlysame

Compare commit time and render counts in React DevTools Profiler before and after.


Real-world cases

  • Charts, tables, virtual scroll: useMemo when data shaping is expensive.
  • Handlers through deep trees: useCallback when intermediate components use memo.
  • Library hooks: some animations/charts need stable callbacks.

Troubleshooting

Memoized but child still renders every time
→ Check new object/function props from parents or a Context value recreated every render.

useMemo not updating
→ Dependencies include a unstable reference.

More complex code, no felt benefit
→ Default rule: do not add until Profiler proves a bottleneck.

Weirdness under Concurrent rendering
→ Keep render pure; side effects belong in useEffect / useLayoutEffect.


Wrap-up

Treat useMemo and useCallback as “reduce render cost” and “stabilize references.” Default to Profiler first, then apply; guard against team-wide over-memoization. Pair with JavaScript patterns for component design.