useCallbackは「関数の参照を保つ」フック、React.memoは「propsが同じなら子の再レンダリングを省く」仕組みで、子の再レンダリングを防ぐには2つをセットで使います。 どちらか片方だけでは、子コンポーネントは親と一緒に毎回描画されます。「useCallbackを付けたのに意味がない」と感じる場面のほとんどは、この組み合わせが片方欠けていることが理由です。

この記事では、同じ画面に「どちらも使わない」「useCallbackだけ」「memoだけ」「両方」「両方+インラインのオブジェクトprops」「それをuseMemoで直したもの」の6パターンを並べ、React 19.3.0 と Chromium で実際に動かしたレンダリング回数とProfilerの計測値を表にしました。さらに同じコードを React Compiler 1.0.0 に通したときの変化も測っています。コードはすべてTypeScriptで、そのまま貼って試せます。

useCallbackとReact.memoの早見表

やりたいこと 使うもの 書き方 効く条件
親が再レンダリングしても子を描画し直さない memo const MemoList = memo(List) 渡すpropsがすべて前回と同じ参照・同じ値
子に渡す関数の参照を保つ useCallback useCallback((id: string) => {...}, []) 受け取る子が memo 済み、またはEffectの依存に使う
子に渡すオブジェクト・配列の参照を保つ useMemo useMemo(() => ({ color: "navy" }), []) 受け取る子が memo 済み
重い計算の結果を使い回す useMemo useMemo(() => heavy(items), [items]) 計算そのものに時間がかかる
すべて自動でやってほしい React Compiler ビルドにプラグインを追加 コンポーネントがReactのルールに沿っている

今回の計測結果(親のボタンを20回押したときの、3,000行リストの再レンダリング回数)は次のとおりです。

パターン 子の再レンダリング 1回あたりの描画時間(Profiler) 判定
どちらも使わない 20回 / 20回 約24ms 毎回描画
useCallbackだけ 20回 / 20回 約26〜39ms 毎回描画(効果なし)
memoだけ(関数はインライン) 20回 / 20回 約20〜36ms 毎回描画(効果なし)
memo + useCallback 0回 / 20回 約0.1ms 省略できた
両方 + インラインのオブジェクトprops 20回 / 20回 約29〜33ms 毎回描画(無効化)
上をuseMemoで直す 0回 / 20回 約0.1〜0.2ms 省略できた

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React useCallbackとReact.memoの使い方と判断表」

検証に使った環境とコード

バージョンと実行方法

項目 内容
React / react-dom 19.3.0(開発ビルド、StrictModeなし)
TypeScript 6.0.3(tsc --noEmit でエラー0件)
バンドル esbuild 0.28.2
ブラウザ Chromium 141(Playwright 1.56.0、ヘッドレス)
React Compiler babel-plugin-react-compiler 1.0.0
計測方法 Profiler コンポーネントの onRender で actualDuration を集計

計測値は React DevTools の画面ではなく、Reactが用意している Profiler コンポーネントのコールバックで取りました。DevToolsのProfilerタブに表示される描画時間と同じ仕組みの値です。DevToolsの画面操作そのものは今回は未実行です。描画時間は実行のたびに数ms〜十数msぶれるため、2回実行した範囲で載せています。大切なのはミリ秒の細かい差ではなく、「20回」か「0回」かの違いです。

共通部分:計測とリスト

リストは3,000行あり、行をクリックすると onSelect が呼ばれます。関数の先頭で呼び出し回数を数えています。

import {
  memo, useCallback, useMemo, useState, Profiler,
  type CSSProperties, type ProfilerOnRenderCallback,
} from "react";
import { createRoot } from "react-dom/client";

type Stat = { listRenders: number; commits: number; total: number };
const stats: Record<string, Stat> = {};
(window as any).__stats = stats;
const stat = (id: string) => (stats[id] ??= { listRenders: 0, commits: 0, total: 0 });

// Profiler:更新時の描画時間を id ごとに合計する
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration) => {
  if (phase !== "update") return;
  const s = stat(id);
  s.commits += 1;
  s.total += actualDuration;
};

const ITEMS = Array.from({ length: 3000 }, (_, i) => `item-${i}`);

type ListProps = {
  label: string;
  onSelect: (item: string) => void;
  rowStyle?: CSSProperties;
};

function List({ label, onSelect, rowStyle }: ListProps) {
  stat(label).listRenders += 1; // 計測用。描画された回数を数える
  return (
    <ul>
      {ITEMS.map((item) => (
        <li key={item} style={rowStyle} onClick={() => onSelect(item)}>
          {item}
        </li>
      ))}
    </ul>
  );
}
const MemoList = memo(List);

描画中に外部の変数を書き換えるのは計測のためだけの書き方です。通常のコードでは入れません。

パターン1:どちらも使わない

function PlainPanel() {
  const [count, setCount] = useState(0);
  const [selected, setSelected] = useState("");
  const handleSelect = (item: string) => setSelected(item);
  return (
    <section>
      <button id="btn-plain" onClick={() => setCount((c) => c + 1)}>
        plain {count} {selected}
      </button>
      <List label="plain" onSelect={handleSelect} />
    </section>
  );
}

ボタンを押すと count が変わり、PlainPanel が再レンダリングされます。Reactは親が再レンダリングされると、propsが変わっていなくても子を再レンダリングします。 これが出発点です。

パターン2:useCallbackだけ

function CallbackOnlyPanel() {
  const [count, setCount] = useState(0);
  const [selected, setSelected] = useState("");
  const handleSelect = useCallback((item: string) => setSelected(item), []);
  return (
    <section>
      <button id="btn-callbackOnly" onClick={() => setCount((c) => c + 1)}>
        callbackOnly {count} {selected}
      </button>
      <List label="callbackOnly" onSelect={handleSelect} />
    </section>
  );
}

パターン3:memoだけ

function MemoOnlyPanel() {
  const [count, setCount] = useState(0);
  const [selected, setSelected] = useState("");
  const handleSelect = (item: string) => setSelected(item); // 毎回新しい関数
  return (
    <section>
      <button id="btn-memoOnly" onClick={() => setCount((c) => c + 1)}>
        memoOnly {count} {selected}
      </button>
      <MemoList label="memoOnly" onSelect={handleSelect} />
    </section>
  );
}

パターン4:memo + useCallback

function BothPanel() {
  const [count, setCount] = useState(0);
  const [selected, setSelected] = useState("");
  const handleSelect = useCallback((item: string) => setSelected(item), []);
  return (
    <section>
      <button id="btn-both" onClick={() => setCount((c) => c + 1)}>
        both {count} {selected}
      </button>
      <MemoList label="both" onSelect={handleSelect} />
    </section>
  );
}

パターン5:両方そろえたうえで、オブジェクトをインラインで渡す

function InlineObjectPanel() {
  const [count, setCount] = useState(0);
  const [selected, setSelected] = useState("");
  const handleSelect = useCallback((item: string) => setSelected(item), []);
  return (
    <section>
      <button id="btn-inlineObject" onClick={() => setCount((c) => c + 1)}>
        inlineObject {count} {selected}
      </button>
      <MemoList label="inlineObject" onSelect={handleSelect} rowStyle={{ color: "navy" }} />
    </section>
  );
}

パターン6:オブジェクトをuseMemoで保つ

function FixedObjectPanel() {
  const [count, setCount] = useState(0);
  const [selected, setSelected] = useState("");
  const handleSelect = useCallback((item: string) => setSelected(item), []);
  const rowStyle = useMemo<CSSProperties>(() => ({ color: "navy" }), []);
  return (
    <section>
      <button id="btn-fixedObject" onClick={() => setCount((c) => c + 1)}>
        fixedObject {count} {selected}
      </button>
      <MemoList label="fixedObject" onSelect={handleSelect} rowStyle={rowStyle} />
    </section>
  );
}

画面への配置

各パネルを Profiler で囲み、id ごとに描画時間を集計します。

function App() {
  return (
    <>
      <Profiler id="plain" onRender={onRender}><PlainPanel /></Profiler>
      <Profiler id="callbackOnly" onRender={onRender}><CallbackOnlyPanel /></Profiler>
      <Profiler id="memoOnly" onRender={onRender}><MemoOnlyPanel /></Profiler>
      <Profiler id="both" onRender={onRender}><BothPanel /></Profiler>
      <Profiler id="inlineObject" onRender={onRender}><InlineObjectPanel /></Profiler>
      <Profiler id="fixedObject" onRender={onRender}><FixedObjectPanel /></Profiler>
    </>
  );
}

createRoot(document.getElementById("root")!).render(<App />);

実行結果:レンダリング回数とProfilerの計測値

各パネルのボタンを20回ずつクリックし、初回描画を除いた回数を数えました。以下は1回目の実行の出力そのままです。

chromium 141.0.7390.37
plain         List再レンダリング 20 /20  commit 20  平均actualDuration 23.90ms
callbackOnly  List再レンダリング 20 /20  commit 20  平均actualDuration 26.46ms
memoOnly      List再レンダリング 20 /20  commit 20  平均actualDuration 36.21ms
both          List再レンダリング  0 /20  commit 20  平均actualDuration 0.12ms
inlineObject  List再レンダリング 20 /20  commit 20  平均actualDuration 32.55ms
fixedObject   List再レンダリング  0 /20  commit 20  平均actualDuration 0.14ms

2回目の実行では、同じ順に 24.38 / 39.38 / 19.83 / 0.15 / 28.61 / 0.20ms でした。再レンダリング回数は2回とも同じです。

読み取れることは3つあります。

  • useCallbackだけ・memoだけでは、20回中20回とも子が描画される。 何も付けない場合と回数が変わりません。
  • 両方そろえると0回になり、描画時間は約24msから約0.1msになる。 パネル自身(ボタンのラベル)の描画だけが残ります。
  • 両方そろえても、オブジェクトをインラインで渡すと20回に戻る。 useMemoで参照を保つと再び0回になります。

なぜuseCallbackだけでは再レンダリングを防げないのか

Reactの既定の動き

Reactは、あるコンポーネントのstateが変わると、そのコンポーネントとその下にあるすべての子を再レンダリングします。このとき「propsが変わったかどうか」は見ていません。propsを比べるのは、子が memo で包まれているときだけです。

子の種類 親が再レンダリングされたとき
通常のコンポーネント propsに関係なく再レンダリングする
memo で包んだコンポーネント propsを1つずつ Object.is で比べ、すべて同じなら省略する

useCallbackがしていること

useCallback(fn, deps) は、依存配列の値が変わらない限り前回と同じ関数オブジェクトを返すだけのフックです。関数の中身を速くするわけでも、子の描画を止めるわけでもありません。

パターン2では handleSelect の参照は毎回同じになりますが、受け取る List が通常のコンポーネントなので、Reactはpropsを比べずに描画します。比べる相手がいないので、参照を保っても結果は変わりません。

memoだけでも防げない理由

パターン3は逆です。MemoList はpropsを比べますが、handleSelect は親が描画されるたびに作り直された別の関数です。JavaScriptでは、中身が同じでも別々に作った関数は等しくなりません。

const a = (item: string) => item;
const b = (item: string) => item;
console.log(Object.is(a, b)); // false
console.log(Object.is({ color: "navy" }, { color: "navy" })); // false
console.log(Object.is("navy", "navy")); // true

onSelect が毎回「変わった」と判定されるため、memo は省略できません。

2つがかみ合う流れ

flowchart TD
    A[親のstateが変わる] --> B[親が再レンダリング]
    B --> C{子はmemo済み?}
    C -- いいえ --> D[子も再レンダリング]
    C -- はい --> E{propsは全部 前回と同じ参照?}
    E -- いいえ --> D
    E -- はい --> F[子の再レンダリングを省略]

memoが「比べる役」、useCallbackとuseMemoが「同じ参照を渡す役」です。両方がそろってはじめて右下の「省略」にたどり着きます。

memoが無効になるprops一覧

パターン5で見たとおり、propsのうち1つでも毎回新しい参照になると memo は省略できません。よくある書き方と、参照を保つ書き方を並べます。

渡し方 毎回新しい参照になる書き方 参照を保つ書き方
インライン関数 onSelect={(item) => setSelected(item)} useCallback で包んだ変数を渡す
オブジェクト rowStyle={{ color: "navy" }} useMemo で包む、またはコンポーネントの外に定数として置く
配列 columns={["id", "name"]} useMemo で包む、またはコンポーネントの外に置く
その場で加工した配列 items={items.filter(isActive)} useMemo(() => items.filter(isActive), [items])
children にJSX MemoCard の子に毎回新しい要素を書く 子の要素を useMemo で保つ、または構成を見直す
文字列・数値・真偽値 (値で比べるので問題なし) そのまま渡す

変わらないオブジェクトは、コンポーネントの外に定数として出すのがいちばん簡単です。フックが要らず、依存配列を考える必要もありません。

const ROW_STYLE: CSSProperties = { color: "navy" }; // コンポーネントの外

function Panel() {
  const [count, setCount] = useState(0);
  const handleSelect = useCallback((item: string) => console.log(item), []);
  return (
    <section>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      <MemoList label="const" onSelect={handleSelect} rowStyle={ROW_STYLE} />
    </section>
  );
}

memo・useMemo・useCallbackの違い

memo useMemo useCallback
種類 コンポーネントを包む関数 フック フック
覚えておくもの 前回のpropsと描画結果 計算結果(値) 関数そのもの
書く場所 コンポーネントの定義側 コンポーネントの中 コンポーネントの中
再計算・再描画のきっかけ propsのどれかが変わった 依存配列の値が変わった 依存配列の値が変わった
主な用途 子の再レンダリングを省く 重い計算の使い回し、オブジェクトの参照を保つ 関数の参照を保つ
単独で効果が出るか propsがプリミティブだけなら出る 重い計算なら出る 出ない(受け取り側しだい)

useCallback(fn, deps) は useMemo(() => fn, deps) と同じ意味です。関数用の短い書き方がuseCallbackと考えると整理しやすくなります。

const handleA = useCallback((item: string) => setSelected(item), []);
const handleB = useMemo(() => (item: string) => setSelected(item), []);
// handleA と handleB はどちらも「参照が保たれた関数」

使う・使わないの判断早見表

メモ化には、依存配列の比較と値の保持というわずかなコストがあり、コードも長くなります。次の表で「付ける理由」が見つかったときに使うと、読みやすさと速さの両方を保てます。

React.memoを使うかどうか

状況 判断 理由
子の描画が重い(長いリスト、グラフ、大きな表) 使う 省略できたときの効果が大きい
親が頻繁に再レンダリングされる(入力欄、タイマー、スクロール連動) 使う 省略できる回数が多い
子のpropsが毎回変わる 使わない 比べても省略できない
子が軽い(ボタン1つ、テキスト数行) 使わない 比べる手間と差がほとんどない
React Compilerを導入済み 原則書かない コンパイラが同じ効果を自動で付ける

useCallbackを使うかどうか

状況 判断 理由
memo 済みの子に関数を渡す 使う 参照が変わると memo が効かなくなる
関数を useEffect の依存配列に入れる 使う 毎回Effectが再実行されるのを防ぐ
カスタムフックから関数を返す 使う 呼び出し側がどう使うか分からないため
button や input など標準の要素に渡すだけ 使わない 標準の要素は memo されておらず効果がない
子が memo されていない 使わない 今回の計測のとおり回数が変わらない

useMemoを使うかどうか

状況 判断 理由
数千件の並べ替え・絞り込み・集計 使う 計算自体に時間がかかる
memo 済みの子にオブジェクトや配列を渡す 使う 参照を保つため
文字列の連結、数個の足し算 使わない 計算のほうが比較より軽い
変わらない定数 使わない コンポーネントの外に出せば済む

迷ったときは、まず何も付けずに書き、Profilerで遅い箇所が見つかってから付ける順番がおすすめです。

依存配列の書き間違いで古い値を参照する例

useCallbackとuseMemoは、依存配列が変わらない限り以前のレンダリングで作った関数や値を返し続けます。関数の中で使っているstateを依存配列に書き忘れると、関数はその時点の値を覚えたままになります。

再現コード

function StalePanel() {
  const [count, setCount] = useState(0);
  const [stale, setStale] = useState("");
  const [fresh, setFresh] = useState("");
  // 依存配列が空:count が 0 のときに作った関数を使い続ける
  const showStale = useCallback(() => setStale(`count=${count}`), []);
  // 依存配列に count:count が変わるたびに関数を作り直す
  const showFresh = useCallback(() => setFresh(`count=${count}`), [count]);
  return (
    <section>
      <button id="btn-inc" onClick={() => setCount((c) => c + 1)}>+1 ({count})</button>
      <button id="btn-stale" onClick={showStale}>stale</button>
      <button id="btn-fresh" onClick={showFresh}>fresh</button>
      <p id="out-stale">{stale}</p>
      <p id="out-fresh">{fresh}</p>
    </section>
  );
}

「+1」を3回押してから「stale」「fresh」を押した結果です。

stale: count=0 / fresh: count=3

画面のカウントは3なのに、依存配列が空の関数は count=0 を表示します。エラーは出ずに値だけが以前のままになるので、気づきにくいのが特徴です。

ESLintが教えてくれる

eslint-plugin-react-hooks(7.1.1)の react-hooks/exhaustive-deps ルールを有効にすると、書き忘れを指摘してくれます。上と同じ形のコードにかけた実際の出力です。

stale.tsx
  6:64  warning  React Hook useCallback has a missing dependency: 'count'. Either include it or remove the dependency array. You can also replace multiple useState variables with useReducer if 'setMessage' needs the current value of 'count'  react-hooks/exhaustive-deps

✖ 1 problem (0 errors, 1 warning)

直し方は3通り

直し方 コード 向いている場面
依存配列に入れる useCallback(() => ..., [count]) 基本。値が変わる頻度が低いとき
更新関数の形にする setCount((c) => c + 1) 前の値から次の値を作るだけのとき。依存が不要になる
useReducerにまとめる dispatch({ type: "added" }) 複数のstateを見て更新するとき。dispatch の参照は変わらない

2つ目の「更新関数の形」は、参照を保ったまま最新の値を使えるので、memo 済みの子に渡す関数と特に相性がよい書き方です。

// 依存配列は空のままで、常に最新の値から計算できる
const increment = useCallback(() => setCount((c) => c + 1), []);

React 19とReact Compilerで手書きのメモ化はどう変わるか

React Compilerとは

React Compilerは、ビルド時にコンポーネントを解析して、memo・useMemo・useCallback に相当する処理を自動で差し込むツールです。公式ドキュメントの紹介ページ(react.dev の React Compiler Introduction、今回開いて確認)では、安定版であること、React 17・18・19で使えること、Babel・Viteなどのビルドツールに対応していることが案内されています。React 19本体にアップグレードしただけで自動的にメモ化されるわけではなく、ビルドにコンパイラを追加したときに有効になります。

同じコードをコンパイラに通した結果

上の6パターンのソースを一切変えずに、babel-plugin-react-compiler 1.0.0 でコンパイルして同じ計測をしました。

plain         List再レンダリング  0 /20  commit 20  平均actualDuration 0.19ms
callbackOnly  List再レンダリング  0 /20  commit 20  平均actualDuration 0.18ms
memoOnly      List再レンダリング  0 /20  commit 20  平均actualDuration 0.18ms
both          List再レンダリング  0 /20  commit 20  平均actualDuration 0.15ms
inlineObject  List再レンダリング  0 /20  commit 20  平均actualDuration 0.16ms
fixedObject   List再レンダリング  0 /20  commit 20  平均actualDuration 0.13ms
パターン コンパイラなし コンパイラあり
どちらも使わない 20回・約24ms 0回・約0.2ms
useCallbackだけ 20回 0回
memoだけ 20回 0回
memo + useCallback 0回 0回
両方 + インラインのオブジェクト 20回 0回
useMemoで直したもの 0回 0回

何も付けていないパターン1が、手書きで両方そろえたパターン4と同じ結果になりました。 インラインのオブジェクトpropsも、コンパイラが参照を保つコードに変換するため、memo が無効になりません。

コンパイラが最適化を見送る書き方

コンパイラは、Reactのルールに沿っていると判断できないコンポーネントをエラーにせず、そのまま(最適化なしで)残します。今回の検証中に、実際に2種類の見送りが起きました。コンパイラのログ出力です。

1つ目は、ESLintのルールをコメントで無効化しているコンポーネントです。前の章の StalePanel に eslint-disable-next-line react-hooks/exhaustive-deps を付けたところ、次のように記録されました。

React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled
React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior.
Found suppression `eslint-disable-next-line react-hooks/exhaustive-deps`

2つ目は、手書きの useCallback の依存配列が、コンパイラの推論と一致しない場合です。最初の版では state を const [, setSelected] = useState("") と、値を受け取らない形で書いていました。すると useCallback(..., []) を持つ4つのパネルが見送りになりました。

Existing memoization could not be preserved
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `setSelected`, but the source dependencies were []. Inferred dependency not present in source

const [selected, setSelected] = useState("") と通常の形に直すと、すべてのパネルがコンパイルされ、上の表の結果になりました。手書きのメモ化が残っているコードほど、依存配列を正確に書いておくことがコンパイラ導入時の助けになります。

手書きが要らなくなるもの・残るもの

手書きのメモ化 コンパイラ導入後 補足
子の再レンダリングを省くための memo 新しいコードでは原則不要 今回の計測で、付けなくても0回になった
子に渡す関数のための useCallback 新しいコードでは原則不要 同上
子に渡すオブジェクトのための useMemo 新しいコードでは原則不要 インラインでも0回になった
Effectの依存に使う値・関数の useMemo / useCallback 必要に応じて残す 公式の紹介ページで、細かく制御したいときの手段として案内されている
既存コードに付いているメモ化 そのまま残す 外すときは動作を確かめながら少しずつ
コンパイラが見送ったコンポーネント 手書きが引き続き有効 ルール無効化コメントや依存配列の不一致を直すと対象になる
コンパイラを入れていない環境 これまでどおり手書き この記事の前半の内容がそのまま当てはまる

コンパイラを試す最小の設定

今回の検証で使ったBabelの設定です。プラグインは他の変換より先に実行されるよう、plugins の先頭に置きます。

// babel.config.mjs の例(今回は @babel/core の transformSync に同じ内容を渡して実行)
export default {
  plugins: [["babel-plugin-react-compiler", {}]],
  presets: [
    ["@babel/preset-typescript", {}],
    ["@babel/preset-react", { runtime: "automatic" }],
  ],
};
npm i -D babel-plugin-react-compiler @babel/core @babel/preset-typescript @babel/preset-react

Viteや Next.js への組み込みは今回は未実行です。各ツールの手順は公式ドキュメントの案内に沿って設定してください。

自分のアプリで計測する手順

「効いているか」を確かめる方法を、手軽な順に並べます。

方法 手順 分かること
Profiler コンポーネント 調べたい範囲を囲み、onRender で actualDuration を記録 範囲ごとの描画時間。テストでも数値を取れる
描画回数のカウンタ 開発中だけ console.count("List") を関数の先頭に置く 何回描画されたか
React DevToolsのProfilerタブ 記録を開始して操作し、停止する どのコンポーネントが、なぜ描画されたか

Profiler コンポーネントを使う最小の例です。

import { Profiler, type ProfilerOnRenderCallback } from "react";

const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration) => {
  console.log(`${id} ${phase} ${actualDuration.toFixed(2)}ms`);
};

export function Measured({ children }: { children: React.ReactNode }) {
  return <Profiler id="list-area" onRender={onRender}>{children}</Profiler>;
}

開発時に StrictMode を使っていると、コンポーネントの関数は確認のために2回ずつ呼ばれます。回数を数えるときは、その分を差し引いて読むか、今回のように StrictMode なしで計測します。

つまずきやすい点と直し方

症状 原因 直し方
useCallbackを付けたのに子が毎回描画される 子が memo されていない 子を memo で包む
memoを付けたのに子が毎回描画される 関数・オブジェクト・配列をインラインで渡している useCallback / useMemo で包む、または外に定数として出す
ボタンを押しても以前の値が表示される 依存配列にstateを書き忘れている 依存配列に加える、または更新関数の形にする
依存配列に入れたら結局毎回作り直される 依存にしている値自体が毎回新しい参照 依存元を先に useMemo で保つ、プリミティブな値だけを依存にする
memo したのにContextの変更で描画される Contextの値の変更はpropsの比較を通らない Contextを分ける、値を useMemo で保つ
コンパイラを入れたのに一部が速くならない そのコンポーネントが見送りになっている ルール無効化コメントと依存配列を見直す

よくある質問

Q. useCallbackは意味がないと聞きました。本当ですか? A. 受け取る側が memo されていない、あるいはEffectの依存にも使っていない場合は、今回の計測のとおり描画回数が変わりません(20回中20回のまま)。memo 済みの子に渡すときは0回になり、はっきり効果が出ます。「どこに渡すか」で意味が決まるフックです。

Q. すべてのコンポーネントをmemoで包んでおけば安心ですか? A. 動作は変わりませんが、propsが毎回変わる子では比較の分だけ仕事が増え、コードも長くなります。描画が重い子、親が頻繁に更新される箇所から付けるのがおすすめです。React Compilerを入れている場合は、コンパイラに任せられます。

Q. useMemoとuseCallbackはどう使い分けますか? A. 保ちたいものが関数ならuseCallback、それ以外の値(オブジェクト・配列・計算結果)ならuseMemoです。useCallback(fn, deps) は useMemo(() => fn, deps) と同じ意味なので、覚えるしくみは1つで済みます。

Q. React 19にしたら、既存のuseCallbackやmemoは外したほうがよいですか? A. React 19にするだけでは自動のメモ化は入りません。React Compilerを追加した場合も、既存のメモ化はそのまま残して問題ありません。外す場合は、Profilerで回数を確かめながら少しずつ進めると安全です。

まとめ

  • useCallbackは「関数の参照を保つ」、memoは「propsが同じなら子を省く」。子の再レンダリングを防ぐには2つをセットで使う。
  • 片方だけでは20回中20回描画され、両方そろえると0回(約24ms → 約0.1ms)になった。
  • インラインの関数・オブジェクト・配列は毎回新しい参照になり、memoが無効になる。 useMemoで包むか、コンポーネントの外に定数として出す。
  • 依存配列の書き忘れは、エラーにならず以前の値を参照し続ける。 react-hooks/exhaustive-deps で検出でき、更新関数の形にすると依存自体を減らせる。
  • React Compiler 1.0.0 に通すと、何も付けていないコードでも子の再レンダリングが0回になった。 新しいコードでは手書きのメモ化は原則不要になる。
  • コンパイラは、ルール無効化コメントや依存配列の不一致があるコンポーネントを見送る。 見送られた箇所では手書きのメモ化が引き続き働く。
  • 迷ったら、まず何も付けずに書き、Profilerで計測してから付ける。

参考

  • React 公式ドキュメント「React Compiler – Introduction」(react.dev/learn/react-compiler/introduction、2026-10-03に閲覧)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React useCallbackとReact.memoの使い方と判断表」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?