useMemo は「計算結果」を、useCallback は「関数そのもの」を再レンダーをまたいで使い回すためのフックで、React.memo は「props が同じなら子コンポーネントの再レンダーを省く」ための仕組みです。 3つはセットで働きます。useCallback で関数の同一性を保っても、受け取る子が React.memo でなければ再レンダーは減りません。

2025年10月に React Compiler 1.0 が安定版になり、新しいコードではメモ化を Compiler に任せ、細かい制御が必要な箇所だけ手動で書くのが React 公式の推奨です。この記事では、React 19.3.0 と babel-plugin-react-compiler 1.0.0 を使い、同じ画面を「最適化なし/手動メモ化/Compiler」の3通りでビルドして、Playwright で再レンダー回数と処理時間を実測しました。さらに Compiler がスキップするコードや、Compiler 導入後も手動メモ化を残す判断基準を、ESLint と Compiler の実際の出力つきで解説します。

useMemo・useCallback・React.memoの早見表

項目 useMemo useCallback React.memo React Compiler
何を使い回すか 計算結果(値) 関数の参照 コンポーネントの描画結果 値・関数・JSX を自動で
書き方 useMemo(() => calc(a), [a]) useCallback((x) => f(x, a), [a]) memo(Component) ビルド設定に追加するだけ
効果が出る条件 計算が重い/参照の同一性が要る 受け取り側が memo か依存配列で使う props が浅い比較で同じ Rules of React を守ったコード
依存配列 必要 必要 不要(props を比較) 不要(自動推論)
単独での効果 あり(重い計算) ほぼなし あり あり
典型的な使い所 一覧の絞り込み・集計 memo 化した子に渡すハンドラ 大きな一覧の行 アプリ全体
Compiler 導入後 原則不要。effect の依存などで残す 原則不要。外部ライブラリ連携などで残す 原則不要 —

判断の最短ルート:新しく書くなら Compiler を入れて手動メモ化は書かない。Compiler を使わないなら「重い計算に useMemo」「memo 化した子に渡す関数に useCallback」「再レンダーが多い大きな子に React.memo」の3点だけ。

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

(記載例)出典:株式会社RJC「useMemo・useCallback使い分け|React Compilerで実測」

検証環境とバージョン

2026年10月1日時点の npm の最新版で確認しました。

パッケージ バージョン
react / react-dom 19.3.0
babel-plugin-react-compiler 1.0.0(latest)
eslint-plugin-react-hooks 7.1.1
@babel/core・@babel/preset-react 8.0.6・8.0.1
esbuild 0.28.2
Playwright / Chromium 1.56.0 / 141.0.7390.37(headless)
Node.js 22

React Compiler は React 19 で最もよく動き、React 17・18 にも対応しています(react.dev の Installation より)。

3つの役割を1つの画面で理解する

検証用コンポーネント(最適化なし版)

2万件のタスクからキーワードで絞り込み、優先度順に並べて上位200件を表示する画面です。行(Row)の再レンダー回数は、props を変えずに数えられるよう useEffect(依存配列なし=描画のたびに実行)でカウントしています。

// src/plain.jsx(最適化なし。Compiler 版もこのファイルをそのまま使う)
import { useState, useEffect } from "react";
import { stats, makeTasks, KEYWORDS } from "./shared.js";

const tasks = makeTasks(20000);

function Row({ task, onSelect }) {
  useEffect(() => { stats.row++; });
  return <li onClick={() => onSelect(task.id)}>{task.title}</li>;
}

function TaskList({ items, onSelect }) {
  useEffect(() => { stats.list++; });
  return <ul>{items.map((t) => <Row key={t.id} task={t} onSelect={onSelect} />)}</ul>;
}

export function App() {
  const [count, setCount] = useState(0);
  const [kwIndex, setKwIndex] = useState(0);
  const [selected, setSelected] = useState(null);
  useEffect(() => { stats.app++; });

  const keyword = KEYWORDS[kwIndex % KEYWORDS.length];
  const visible = tasks
    .filter((t) => t.title.includes(keyword))
    .sort((a, b) => a.priority - b.priority)
    .slice(0, 200);
  const handleSelect = (id) => setSelected(id);

  return (
    <div>
      <button id="inc" onClick={() => setCount((c) => c + 1)}>count {count}</button>
      <button id="kw" onClick={() => setKwIndex((i) => i + 1)}>keyword [{keyword}]</button>
      <p>selected: {selected}</p>
      <TaskList items={visible} onSelect={handleSelect} />
    </div>
  );
}
// src/shared.js
export const stats = { app: 0, list: 0, row: 0 };
export function makeTasks(n) {
  const tasks = [];
  for (let i = 0; i < n; i++) {
    tasks.push({ id: i, title: `task-${(i * 7919) % 100000}`, priority: (i * 31) % 97 });
  }
  return tasks;
}
export const KEYWORDS = ["", "1", "12", "3", "45", "6", "7", "8", "9", "0"];

手動メモ化版(3つを組み合わせる)

変更点は4か所だけです。 子2つを memo で包み、絞り込み結果を useMemo、ハンドラを useCallback にしました。

// src/manual.jsx(差分のある部分)
import { useState, useEffect, useMemo, useCallback, memo } from "react";

const Row = memo(function Row({ task, onSelect }) {
  useEffect(() => { stats.row++; });
  return <li onClick={() => onSelect(task.id)}>{task.title}</li>;
});

const TaskList = memo(function TaskList({ items, onSelect }) {
  useEffect(() => { stats.list++; });
  return <ul>{items.map((t) => <Row key={t.id} task={t} onSelect={onSelect} />)}</ul>;
});

// App の中
const visible = useMemo(
  () => tasks
    .filter((t) => t.title.includes(keyword))
    .sort((a, b) => a.priority - b.priority)
    .slice(0, 200),
  [keyword]
);
const handleSelect = useCallback((id) => setSelected(id), []);

3つの役割分担はこうなります。

部品 なくした場合に起きること
useMemo(visible) count が変わるたびに2万件の絞り込みとソートが走り、配列も毎回新しくなる
useCallback(handleSelect) 毎回新しい関数になり、Row の memo が「props が変わった」と判断する
memo(TaskList・Row) 親が再レンダーすると、props が同じでも子が全部再レンダーされる

Compiler版のビルド設定

Compiler 版は plain.jsx をそのまま使い、Babel の設定にプラグインを1つ足すだけです。Compiler は Babel のプラグインの中で最初に実行させます(react.dev の指定)。

{
  "plugins": ["babel-plugin-react-compiler"],
  "presets": [["@babel/preset-react", { "runtime": "automatic" }]]
}
npm i react@19.3.0 react-dom@19.3.0
npm i -D @babel/core @babel/cli @babel/preset-react babel-plugin-react-compiler@1.0.0 esbuild playwright@1.56.0
NODE_ENV=production npx babel src --out-dir lib/compiler --config-file ./babel.compiler.json
npx esbuild lib/compiler/entry-plain.js --bundle --minify \
  --define:process.env.NODE_ENV='"production"' --outfile=dist/compiler.js

Vite を使う場合は、react.dev の手順では @vitejs/plugin-react 6 以降で次の設定です(この記事の計測では Babel CLI と esbuild を使い、Vite の設定は未実行です)。

// vite.config.js(@vitejs/plugin-react v6 以降)
import { defineConfig } from "vite";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";

export default defineConfig({
  plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
});

Compiler が出力したコード(App の一部) を見ると、useMemo を書いていないのに、keyword が変わったときだけ絞り込みを再計算し、handleSelect は初回だけ作るようになっています。

// lib/compiler/plain.js(Compiler 1.0.0 の出力から抜粋)
import { c as _c } from "react/compiler-runtime";
export function App() {
  const $ = _c(18);
  // ...
  let t0;
  if ($[0] !== keyword) {
    t0 = tasks.filter(t => t.title.includes(keyword)).sort(_temp4).slice(0, 200);
    $[0] = keyword;
    $[1] = t0;
  } else {
    t0 = $[1];
  }
  const visible = t0;
  let t1;
  if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
    t1 = id => setSelected(id);
    $[2] = t1;
  } else {
    t1 = $[2];
  }
  const handleSelect = t1;

再レンダー回数と処理時間の実測結果

計測方法

  • 3つを本番ビルド(NODE_ENV=production)でバンドルし、Playwright で headless Chromium に読み込む
  • ボタンや行を flushSync(() => el.click()) で同期的に更新し、performance.now() で合計時間を計る
  • 再レンダー回数は、各コンポーネントの useEffect のカウント
  • 各シナリオを新しいページで5回ずつ実行し、時間は中央値
// measure.mjs(抜粋)
const r = await page.evaluate(async ({ sel, n }) => {
  const s = window.__stats; const base = { ...s };
  const t0 = performance.now();
  for (let i = 0; i < n; i++) {
    const el = document.querySelectorAll(sel)[sel === "li" ? i % 10 : 0];
    window.__flushSync(() => el.click());
  }
  const ms = performance.now() - t0;
  await new Promise((r) => setTimeout(r, 50));
  return { ms, app: s.app - base.app, list: s.list - base.list, row: s.row - base.row };
}, sc);

実行結果

┌─────────┬────────────┬────────────────────────────────────┬───────────┬─────┬──────┬───────┐
│ (index) │ variant    │ scenario                           │ median_ms │ app │ list │ row   │
├─────────┼────────────┼────────────────────────────────────┼───────────┼─────┼──────┼───────┤
│ 0       │ 'none'     │ '無関係なstate更新(count+1)×100' │ 503.6     │ 100 │ 100  │ 20000 │
│ 1       │ 'none'     │ '検索キーワード変更×20'            │ 103       │ 20  │ 20   │ 4000  │
│ 2       │ 'none'     │ '行クリックで選択×50'              │ 216.3     │ 50  │ 50   │ 10000 │
│ 3       │ 'manual'   │ '無関係なstate更新(count+1)×100' │ 11.2      │ 100 │ 0    │ 0     │
│ 4       │ 'manual'   │ '検索キーワード変更×20'            │ 83.8      │ 20  │ 20   │ 3088  │
│ 5       │ 'manual'   │ '行クリックで選択×50'              │ 8.1       │ 50  │ 0    │ 0     │
│ 6       │ 'compiler' │ '無関係なstate更新(count+1)×100' │ 10        │ 100 │ 0    │ 0     │
│ 7       │ 'compiler' │ '検索キーワード変更×20'            │ 85.5      │ 20  │ 20   │ 4000  │
│ 8       │ 'compiler' │ '行クリックで選択×50'              │ 7.2       │ 50  │ 0    │ 0     │
└─────────┴────────────┴────────────────────────────────────┴───────────┴─────┴──────┴───────┘
{"chromium":"141.0.7390.37"}
シナリオ 最適化なし 手動メモ化 Compiler
無関係な state 更新×100:Row の再レンダー 20,000回 0回 0回
同:合計時間 503.6ms 11.2ms 10.0ms
キーワード変更×20:Row の再レンダー 4,000回 3,088回 4,000回
同:合計時間 103.0ms 83.8ms 85.5ms
行クリック×50:Row の再レンダー 10,000回 0回 0回
同:合計時間 216.3ms 8.1ms 7.2ms

結果から読み取れること

  • 無関係な state 更新と行クリックでは、手動メモ化と Compiler がほぼ同じ効果(1回あたり約5ms → 約0.1ms)。Compiler はメモ化を1行も書かずに同等の結果になりました。
  • キーワード変更で Row の回数に差が出ました。 手動版は memo(Row) なので、前後の一覧に同じ task オブジェクトが残っている行は再レンダーを省きます(4,000 → 3,088回)。Compiler 版は Row を memo で包む変換ではなく、Row の中の JSX をキャッシュする方式なので、新しい一覧で作られた Row は関数としては呼ばれますが、中の描画結果は使い回されます。合計時間の差は 1.7ms でした。
  • 最適化なしでも、キーワード変更のように「本当に表示が変わる」更新では差は小さい(103ms と 84〜86ms)。メモ化が大きく効くのは「表示が変わらないのに再レンダーされる」場面です。

数値はこのコンテナの headless Chromium での値で、端末によって変わります。比べるときは同じ条件での相対値として見てください。

Compiler導入後も手動メモ化が要るケースの判断フロー

react.dev は、新しいコードは Compiler に任せ、精密な制御が必要なところで useMemo・useCallback を使う。既存のメモ化は残すか、外すならテストしてからと案内しています。外すと Compiler の出力が変わることがあるためです。

flowchart TD
  A[メモ化を書くか迷う] --> B{Compiler を導入済み?}
  B -- いいえ --> C{重い計算 or memo化した子に渡す or 依存配列で使う?}
  C -- はい --> D[useMemo / useCallback / memo を書く]
  C -- いいえ --> E[書かない]
  B -- はい --> F{ESLint の react-hooks で<br>コンパイルがスキップされた?}
  F -- はい --> G[指摘を直す。直せないなら手動メモ化]
  F -- いいえ --> H{値を effect の依存配列に入れる?}
  H -- はい --> I[useMemo / useCallback で同一性を明示]
  H -- いいえ --> J{外部ライブラリが参照の同一性を前提にする?}
  J -- はい --> I
  J -- いいえ --> K[書かない。Compiler に任せる]
ケース 手動メモ化 理由
値が effect の依存配列に入り、意味のない再実行を避けたい 残す react.dev が明示している用途。意図をコードに残せる
Compiler がスキップした部品(下の ESLint の指摘) 指摘を直すのが先。直せなければ残す スキップされた部品は自動メモ化されない
外部ライブラリに渡すオプションやコールバックで、参照が変わると再初期化される 残す 同一性の要件をコードで明示できる
既存コードの useMemo・useCallback まず残す 外すと出力が変わることがある。外すならテストしてから
新しく書く普通の部品 書かない Compiler が自動で行う

Compiler がスキップするコードと ESLint の指摘

eslint-plugin-react-hooks 7 系には、Compiler と同じ解析を使うルールが入っています(recommended 設定に含まれます)。次のファイルで確認しました。

// eslint.config.js(ESLint 10.11.0、flat config)
import reactHooks from "eslint-plugin-react-hooks";
export default [
  {
    files: ["lint/**/*.jsx"],
    languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } },
    plugins: { "react-hooks": reactHooks },
    rules: reactHooks.configs.recommended.rules,
  },
];
// lint/samples.jsx
import { useMemo, useCallback, useEffect, useRef, useState } from "react";

// 1. 依存配列の書き忘れ
export function Total({ items, taxRate }) {
  const total = useMemo(
    () => items.reduce((s, i) => s + i.price, 0) * (1 + taxRate),
    [items]
  );
  return <p>{total}</p>;
}

// 2. props を書き換える
export function Badge({ user }) {
  user.label = user.name.toUpperCase();
  return <span>{user.label}</span>;
}

// 3. レンダー中に ref.current を読む
export function Counter() {
  const renders = useRef(0);
  renders.current += 1;
  return <p>{renders.current}</p>;
}

// 4. useCallback の依存配列が実際の依存と違う
export function Mirror({ value }) {
  const [copy, setCopy] = useState(value);
  if (copy !== value) setCopy(value);
  const onClick = useCallback(() => console.log(copy), []);
  return <button onClick={onClick}>{copy}</button>;
}

// 5. effect 内で同期的に setState
export function Loader({ id }) {
  const [label, setLabel] = useState("");
  useEffect(() => {
    setLabel(`item-${id}`);
  }, [id]);
  return <p>{label}</p>;
}

npx eslint lint/ の出力(コード枠と長い説明文は一部省略):

   7:5   warning  React Hook useMemo has a missing dependency: 'taxRate'. Either include it or remove the dependency array  react-hooks/exhaustive-deps
  14:3   error    Error: This value cannot be modified
Modifying component props or hook arguments is not allowed. Consider using a local variable instead.  react-hooks/immutability
  21:3   error    Error: Cannot access refs during render  react-hooks/refs
  21:3   error    Error: Cannot access refs during render  react-hooks/refs
  22:14  error    Error: Cannot access refs during render  react-hooks/refs
  29:56  warning  React Hook useCallback has a missing dependency: 'copy'. Either include it or remove the dependency array  react-hooks/exhaustive-deps
  37:5   error    Error: Calling setState synchronously within an effect can trigger cascading renders  react-hooks/set-state-in-effect

✖ 7 problems (5 errors, 2 warnings)

同じファイルを Compiler の logger オプションつきでビルドし、どの部品がコンパイルされ、どれがスキップされたかを確認しました。

// babel.log.cjs
module.exports = {
  plugins: [["babel-plugin-react-compiler", { logger: { logEvent(file, e) {
    console.error(JSON.stringify({ kind: e.kind, fnName: e.fnName, reason: e.detail?.reason }));
  } } }]],
  presets: [["@babel/preset-react", { runtime: "automatic" }]],
};
部品 Compiler の結果 理由(ログの reason)
Total CompileSuccess —
Badge CompileError(スキップ) This value cannot be modified
Counter CompileError(スキップ) Cannot access refs during render
Mirror CompileError(スキップ) Existing memoization could not be preserved
Loader CompileSuccess ESLint はエラーでもコンパイルは行われる

Mirror のログ全文は、手動メモ化と Compiler の関係をよく表しています。

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 `copy`, but the source dependencies were []. Inferred dependency not present in source

依存配列を [copy] に直すと、Mirror は CompileSuccess(memoSlots 5)になりました。 手動メモ化の依存配列が間違っていると、Compiler はその部品全体を最適化しません。

外部ライブラリとの組み合わせ(incompatible-library)

react-hook-form 7.89.0 の watch() を使う部品は、ESLint が警告し、Compiler もスキップしました。

import { useForm } from "react-hook-form";

export function NameForm() {
  const { register, watch } = useForm();
  const name = watch("name");
  return (
    <form>
      <input {...register("name")} />
      <p>{name}</p>
    </form>
  );
}
  5:16  warning  Compilation Skipped: Use of incompatible library

This API returns functions which cannot be memoized without leading to stale UI. To prevent this, by default React Compiler will skip memoizing this component/hook. However, you may see issues if values from this API are passed to other components/hooks that are memoized.

> 5 |   const name = watch("name");
    |                ^^^^^ React Hook Form's `useForm()` API returns a `watch()` function which cannot be memoized safely.
                                                    react-hooks/incompatible-library

このような部品は Compiler に任せず、必要なら手動でメモ化するか、値を購読する別の API(react-hook-form なら useWatch など)に置き換えられないか確認します。

特定の部品だけ Compiler を止める

export function Plain({ items }) {
  "use no memo";
  const sorted = [...items].sort();
  return <p>{sorted.join(",")}</p>;
}

ログは CompileSkip になり、出力に _c( が含まれないことを確認しました。一時的な切り分け用として使い、原因を直したら外します。

よくある誤用と直し方

依存配列の書き忘れ

// 直す前:taxRate が変わっても total が更新されない
const total = useMemo(() => sum(items) * (1 + taxRate), [items]);

// 直した後:コールバック内で使う値はすべて依存配列へ
const total = useMemo(() => sum(items) * (1 + taxRate), [items, taxRate]);

react-hooks/exhaustive-deps の警告は、そのまま直すのが基本です。 警告を // eslint-disable-next-line で消すと、上の Mirror のように Compiler の最適化も止まります。

何でも useMemo にする

書き方 効果 理由
useMemo(() => a + b, [a, b]) ほぼなし 足し算より依存配列の比較のほうが手間
useMemo(() => items.length, [items]) ほぼなし 計算が軽い
useCallback を memo でない子に渡す なし 子は毎回再レンダーされる
useMemo(() => <Child />, []) 用途による 子の memo で書くほうが読みやすい
2万件の filter・sort あり 上の計測で 1回約5ms → 約0.1ms

useCallback を単独で使う

useCallback は「受け取る側」が同一性を使うときだけ効きます。 受け取る側とは、memo された子、または useEffect・useMemo の依存配列です。上の計測でも、Row が memo でなければ handleSelect を固定しても行の再レンダーは減りません。

オブジェクトや配列を props に毎回新しく渡す

// 直す前:style が毎回新しいオブジェクトになり、memo(Card) が効かない
<Card style={{ color: "gray" }} />

// 直した後:変わらない値はコンポーネントの外へ
const CARD_STYLE = { color: "gray" };
<Card style={CARD_STYLE} />

Compiler を使っている場合は、この形も自動でキャッシュされます。

よくある質問

Q. React Compiler を入れたら、既存の useMemo・useCallback は消すべきですか? A. react.dev は「残すか、外すなら慎重にテストしてから」と案内しています。外すと Compiler の出力が変わることがあるためです。新しく書くコードでは、手動メモ化は必要な箇所だけにします。

Q. useMemo と useCallback の違いを一言でいうと? A. useCallback(fn, deps) は useMemo(() => fn, deps) と同じ意味です。値を返すなら useMemo、関数そのものを使い回すなら useCallback と覚えると迷いません。

Q. Compiler がちゃんと効いているかはどう確認しますか? A. React DevTools で、最適化された部品の名前の横に「Memo」のバッジが付きます(react.dev より)。ビルド出力に react/compiler-runtime の _c( が入っているかでも確認できます。スキップの理由は ESLint の react-hooks ルールか、Compiler の logger で確認できます。

Q. React.memo はもう要りませんか? A. Compiler 導入後は原則不要です。ただし今回のキーワード変更のように、一覧の行を前後で使い回したい場面では memo(Row) のほうが再レンダー回数は少なくなりました。時間差は小さいので、計測して差が大きい場合に検討するとよいです。

まとめ

  • useMemo は値、useCallback は関数、React.memo は子の再レンダーを使い回す。3つはセットで効く
  • 実測では、無関係な state 更新×100 で Row の再レンダーが 20,000回 → 0回(手動・Compiler とも)、時間は 503.6ms → 約10ms
  • Compiler 1.0 はメモ化を書かずに手動と同等の効果。一覧の差し替えでは memo(Row) と仕組みの違いが出た
  • effect の依存配列・外部ライブラリの同一性要件・スキップされた部品では手動メモ化を検討する
  • 依存配列の間違いは Compiler の最適化も止める(Existing memoization could not be preserved)
  • ESLint の react-hooks 7 系で、Compiler がスキップする理由を事前に確認できる

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

(記載例)出典:株式会社RJC「useMemo・useCallback使い分け|React Compilerで実測」

株式会社RJCでは、Reactの再レンダーとメモ化の考え方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社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で一緒に開発しながら、 成長を楽しみませんか?