React Compilerを入れたらuseMemoとuseCallbackはいらなくなるのか、動かして確かめた

​​React​React Compiler,TypeScript,useCallback,useMemo

React Compilerを入れると、新しく書くコードでは useMemo と useCallback をほとんど書かなくてよくなります。ただし「完全に不要」ではありません。コンパイラが処理をスキップしたコンポーネントや、エフェクトの依存値を細かく制御したい場面では、今も手で書く意味が残ります。

筆者は検証環境で、TypeScript(.tsx)で書いた同じコンポーネントをコンパイラあり・なしで変換し、子の描画回数とエフェクトの実行回数を数えました。その結果と、公式ドキュメントの方針をまとめます。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、babel-plugin-react-compiler 1.0.0、@babel/core 8.0.7、@babel/preset-typescript 8.0.7、typescript 7.0.2、@types/react 19.3.0、jsdom 29.1.1 です。コード例は .tsx で書き、tsc で型チェックしてから、Babelで変換してjsdomの上で描画しました。ViteやNext.jsのビルドでは試していません。画像は、同じ変換結果を esbuild でまとめ、Playwright 1.56.1 の Chromium で動かして撮りました。

公式の答えは「基本は任せる、必要なところだけ書く」

公式ドキュメントの React Compilerの紹介ページ には、useMemo・useCallback・React.memo をどうすればよいかが書かれています。要点は次のとおりです。

  • コンパイラは解析にもとづいて自動でメモ化する。多くの場合、手で書いたものと同じか、それ以上に細かい。
  • useMemo と useCallback は、メモ化を細かく制御したいときの「逃げ道(escape hatch)」として、コンパイラと一緒に使い続けられる。よくある例は、メモ化した値をエフェクトの依存値に使う場合。
  • 新しいコードは、コンパイラに任せ、必要なところだけ useMemo / useCallback を使う。
  • 既存のコードは、今あるメモ化をそのまま残すか、外す前にしっかりテストする。外すとコンパイル結果が変わることがあるため。

同じ内容は React Compiler v1.0 の発表記事 にもあります。

useMemoなしの親で、子の再描画とエフェクトが止まるか

まず、useMemo も useCallback も書いていない親コンポーネントを用意しました。親はボタンを押すたびに自分の state を更新します。子は memo で包んであり、親から配列と関数を受け取ります。

tsx
export function Parent({ data }: { data: number[] }) {
  const [count, setCount] = useState(0);
  const items = data.filter((x) => x % 2 === 0);                  // 毎回新しい配列
  const handleSelect = (i: number) => console.log('select', i);   // 毎回新しい関数
  const options = { size: items.length };                          // 毎回新しいオブジェクト
  useEffect(() => { log.effectRuns++; }, [options]);
  return (
    <div>
      <button id="inc" onClick={() => setCount(count + 1)}>{count}</button>
      <Child items={items} onSelect={handleSelect} />
    </div>
  );
}

コンパイラなしでは、配列と関数が毎回作り直されます。そのため memo があっても子は毎回描画され、エフェクトも毎回動くはずです。初回の描画のあと、ボタンを3回押した結果がこちらです。

text
$ node run.mjs ./out/app.plain.mjs
./out/app.plain.mjs: button=3 childRenders=4 effectRuns=4

$ node run.mjs ./out/app.compiled.mjs
./out/app.compiled.mjs: button=3 childRenders=1 effectRuns=1

コンパイラなし(plain)では、子の描画もエフェクトも4回です。コンパイラあり(compiled)では、どちらも初回の1回だけでした。useMemo も useCallback も書いていないのに、手で書いたときと同じ効果が出ています。

TypeScriptで書いても、Babelの設定はコンパイラのプラグインと @babel/preset-react に @babel/preset-typescript を足すだけでした。型注釈は変換の途中で取り除かれます。同じコンポーネントをJavaScript(.jsx)で書いた版でも試しましたが、この記事のどの計測も同じ回数になりました。

同じ2つの変換結果を、ブラウザでも並べて動かしました。esbuildで1つのファイルにまとめ、Playwright 1.56.1 の Chromium で上下のボタンを交互に3回ずつ押しました。1コマずつ撮って、0.08秒間隔でつないだGIFアニメーションです。画面上部の帯に何回目のクリックかが出て、押した直後に変わった数字はピンクになります。画面の下には、元のコードが同じ src/app.tsx であることを書き添えました。上(コンパイラなし)は押すたびに子の描画回数とエフェクトの実行回数が増え、下(コンパイラあり)は1のままです。

コンパイラなしとありの親を上下に並べ、カーソルで両方のボタンを交互に3回ずつ押す。上のコンパイラなしは子の描画回数とエフェクトの実行回数が押すたびにピンクで光って4まで増え、下のコンパイラありはボタンの数字だけが増えて、どちらの回数も1のまま。いちばん下に「どちらも同じ src/app.tsx(TypeScript)を Babel で変換したもの」と書いてある
GIFアニメーション:上下のボタンを交互に3回ずつ押したときの、描画回数とエフェクトの実行回数の動き

なお、検証で使った子コンポーネントは、描画回数を数えるためにモジュールの変数を書き換えています。これはReactのルール違反なので、子のほうはコンパイラにスキップされていました(検証ログでは line 8 のエラーとして記録)。子は memo で包んであるため、この比較には影響しません。

変換後のコードを見ると、理由が分かります。コンパイラは値ごとにキャッシュの枠($[0] など)を用意し、元になる値が変わったときだけ作り直します。

js

let t1;
  if ($[0] !== data) {
    t1 = data.filter(_temp);
    $[0] = data;
    $[1] = t1;
  } else {
    t1 = $[1];
  }
  const items = t1;
  const handleSelect = _temp2;

handleSelect は外側の値を使っていないため、コンポーネントの外に出された関数(_temp2)になりました。毎回同じ関数が渡るので、子の memo が効きます。

すでに書いたuseMemoはどうなるか

次に、useMemo と useCallback をすでに書いたコンポーネントを変換しました。

tsx
type ListProps = { data: number[]; onPick: (i: number) => void };

export function List({ data, onPick }: ListProps) {
  const items = useMemo(() => data.filter((x) => x % 2 === 0), [data]);
  const handle = useCallback((i: number) => onPick(i), [onPick]);
  return <ul>{items.map((i) => <li key={i} onClick={() => handle(i)}>{i}</li>)}</ul>;
}

変換後のコードで useMemo と useCallback を探すと、残っていたのは import の行だけでした。

text
$ grep -n 'useMemo\|useCallback' out/existing.compiled.mjs
2:import { useMemo, useCallback } from 'react';

呼び出しは、コンパイラ自身のキャッシュ(if ($[0] !== data) { ... } の形)に置き換わっていました。今回の例では、手で書いたメモ化がそのまま二重に残るわけではありません。ただ、公式は「外すとコンパイル結果が変わることがある」としています。既存のコードから消すかどうかは、上の方針どおりテストしてから決めるのが安全です。

コンパイラがスキップしたら、メモ化はゼロに戻る

コンパイラは Reactのルール に従ったコードを前提にしています。デバッグのページ によると、ルールを破っていそうなコードを見つけると、動きを変えてしまう危険を避けて、そのコンポーネントの最適化をスキップします。

そこで、さきほどの親に「レンダー中に props を書き換える」1行を足し、書き換えられるように props の型も変えました。これはルール違反ですが、型の上では書き換えてよいことになっているので、tsc の型チェックはエラーなしで通ります。

tsx
type Data = number[] & { lastRendered?: number };

export function Parent({ data }: { data: Data }) {
  data.lastRendered = Date.now();   // レンダー中に props を書き換える(ルール違反)
  // 以下は同じ

コンパイラのログには、次のエラーが出ました。ビルド自体は失敗せず、このコンポーネントは変換されずに元のまま出力されました。

text
CompileError line 16 | This value cannot be modified | Modifying component props or hook arguments is not allowed. Consider using a local variable instead

この状態で、useMemo なしの版と、useMemo / useCallback を手で書いた版を比べました。どちらもコンパイラを通しています。

text
$ node run.mjs ./out/bailout.compiled.mjs
./out/bailout.compiled.mjs: button=3 childRenders=4 effectRuns=4

$ node run.mjs ./out/bailout-manual.compiled.mjs
./out/bailout-manual.compiled.mjs: button=3 childRenders=1 effectRuns=1
注意

コンパイラがスキップしたコンポーネントは、メモ化のないコードとして動きます。手書きの useMemo を消したあとにスキップされると、子の再描画やエフェクトの空振りが元に戻ります。

手書きの版は、コンパイラにスキップされても1回で止まりました。既存のメモ化を「残すか、テストしてから外す」という公式の方針は、こういう場面に効いてきます。

なお、関数の先頭に "use no memo" と書いても、コンパイラはその関数をスキップします。検証環境でも、付けた版は子の描画とエフェクトが4回ずつになりました。公式はこの指示を、デバッグのための一時的な手段としています。

ここまでの5つの版の計測結果を、1つのグラフにまとめました。どの版でも、エフェクトの実行回数は子の描画回数と同じでした。

TypeScriptで書いた5つの版で、ボタンを3回押したあとの子の描画回数を比べた横棒グラフ。コンパイラなし、ルール違反でスキップした useMemo なしの版、&quot;use no memo&quot; を付けた版が4回、コンパイラありと、スキップされても useMemo を手で書いた版が1回

今も手で書く意味がある場面

ここまでの結果と公式ドキュメントから、useMemo / useCallback を書く意味が残るのは次の場面です。

  • エフェクトの依存値を、意図したときだけ変わるように細かく制御したいとき(公式が挙げている例)
  • コンパイラがスキップしたコンポーネント(ルール違反がある、"use no memo" が付いているなど)
  • 既存のコードで、外したときの影響をまだテストしていないとき

逆に、コンポーネントでもフックでもない、ふつうの関数はコンパイラの対象外です。検証環境でも、ただの関数は変換されずにそのまま出力されました。紹介ページ は、重い計算を複数のコンポーネントで使うなら、React の外で独自にメモ化することも考えるよう書いています。コンパイラのメモ化は、コンポーネントやフックの間で共有されないためです。

スキップされていないかを確かめる方法

どのコンポーネントがスキップされたかは、見た目では分かりません。筆者の検証では、Babelプラグインの logger オプション でイベントを受け取り、CompileSuccess と CompileError を出力して確かめました。

ts
import type { Logger } from 'babel-plugin-react-compiler';

const logger: Logger = {
  logEvent: (filename, event) => console.log(event.kind, 'fnName' in event ? event.fnName ?? '' : ''),
};
// Babel の設定: plugins: [['babel-plugin-react-compiler', { logger }]]

event はイベントの種類ごとに形が違う型です。検証環境で event.fnName と直接書くと、tsc がエラーにしました。そのため 'fnName' in event で確かめてから読んでいます。

v1.0 の発表記事 によると、ルール違反は eslint-plugin-react-hooks の推奨設定でも見つけられます。このリンターはコンパイラを入れていなくても使えるとされています。筆者はESLintでの確認は試していません。

ポイント

新しいコードでは、まずコンパイラに任せて useMemo / useCallback を書かずに済ませられます。既存のメモ化は、コンパイラにスキップされたときの保険にもなるので、テストしてから外すかを決めます。

​Posted by 86