魔法のコンパイラがもたらす「静かなる破壊」
深夜の障害対応で、コードを1行も変更していないはずの画面の時計がピタリと止まっているのを発見したときの戦慄を想像してみてほしい。あるいは、本番環境で特定のユーザー行動ログがパタリと途絶え、原因を追うと「レンダリングの最適化」の裏でログ出力関数ごとキャッシュの闇に葬り去られていたとしたら――。我々フロントエンドエンジニアは、React Compilerという「魔法の杖」を手に入れたと歓喜した。TSKaigi 2026での芹澤和也氏のセッション「アンチパターンを避ける型駆動React最適化」が示すように、メモ化(useMemoやuseCallback)の手動管理から解放され、コンパイラが自動でコードを最適化してくれる未来が到来したのだ。しかし、この甘美な約束の裏には、極めて冷酷な現実が隠されている。それは、「純粋ではないコード」をコンパイラに通したとき、コンパイラは安全に処理を諦めてくれるのではなく、最適化という名のもとにコードの挙動をサイレントに書き換えてしまうという事実だ。
React Compiler(babel-plugin-react-compiler)の内部では、react/compiler-runtime から提供される _c(useMemoCache)という内部フックが使われている。これは、コンポーネントのインスタンスに紐づくキャッシュ配列を確保し、前回の入力値と今回の入力値を比較して、変化がなければキャッシュされた結果を返すという、極めて愚直な if/else の分岐コードを生成する。この仕組み自体は美しい。しかし、この「愚直な比較とキャッシュ」こそが、Rules of Reactを破ったコードに対して牙をむく。コンパイラは、我々のコードが「純粋(Pure)」であることを前提に最適化の牙を研いでいるのだ。我々が「コンパイラが諦めてくれるなら遅くなるだけで壊れはしない」と高を括っていた設計思想は、この瞬間に完全に崩壊する。
3つのルール違反が招く「最適化」の罠
では、実際にReactが掲げる「純粋性」のルールを破ったとき、コンパイラはどう振る舞うのか。具体的な検証結果を見ていこう。公式ドキュメントの「Rules of React」から、特に重要とされる3つのルールを意図的に破り、その出力を観察した。
まず、「Props and state are immutable(PropsとStateは不変であるべき)」というルールだ。コンポーネントに渡されたPropsを直接書き換える(todo.label = todo.label.trim())と、コンパイラは賢く CompileError(This value cannot be modified)を吐き、最適化をスキップして素通しにする。ここまでは我々の期待通りだ。しかし、この書き換えを forEach ループの内部で行ったり、todos.sort() のように配列そのものを並び替えるメソッドを呼び出すと、コンパイラは沈黙し、CompileSuccess として最適化コードを出力してしまう。つまり、間接的なミューテーションはコンパイラの静的解析の網をすり抜けてしまうのだ。
次に、「Side effects must run outside of render(副作用はレンダーの外で実行すべき)」だ。レンダー中に logger.info のようなログ出力を挟むと、コンパイラはこれを「最適化の対象」としてキャッシュ判定の if ブロックの内部に閉じ込めてしまう。結果として、Propsが変化しない限りログは二度と出力されなくなる。ログの位置やJSXの構造によって、この副作用がキャッシュに巻き込まれるかどうかがサイレントに変化する。これはデバッグを困難にする極めて危険な挙動だ。
そして最も致命的なのが、「Components and Hooks must be idempotent(コンポーネントとフックは冪等であるべき)」というルールだ。レンダー中に new Date() を用いて現在時刻を表示しようとすると、コンパイラは Symbol.for("react.memo_cache_sentinel") を用いた初回のみ実行されるキャッシュコードを生成する。結果、画面の時刻は初回のレンダリング時のまま完全にフリーズする。これらの挙動を以下の表にまとめる。
| 破ったルール | コンパイラの報告 | 出力コードの挙動 | 発生する現象 |
|---|---|---|---|
| Props and state are immutable (直接代入) | CompileError | 最適化をスキップ(素通し) | パフォーマンスは低下するが、動作は壊れない |
| Props and state are immutable (forEach/sort) | CompileSuccess | キャッシュ判定の内外にコードが再配置される | 意図しないタイミングでのデータ書き換えが発生 |
| Side effects must run outside of render | CompileSuccess | 副作用がキャッシュ判定の内部に取り込まれる | キャッシュが効いている間、ログ等の副作用が実行されない |
| Components and Hooks must be idempotent | CompileSuccess | 初回レンダリング時の値で完全にキャッシュされる | new Date() などの動的な値が二度と更新されなくなる |
コンパイラがコードを壊したのではない。我々が元から抱えていた「ルール違反」という爆弾が、最適化という起爆剤によって一斉に爆発したのだ。
型とLintを超えた「防衛的React」の処方箋
この「静かなる破壊」から身を守るために、我々は何ができるのだろうか。まず思い浮かぶのは、TypeScriptの型システムやESLintによる静的解析だ。確かに、Propsを Readonly<T> や ReadonlyArray<Readonly<T>> で定義すれば、直接的な代入や sort() のような破壊的操作の多くはコンパイルエラーとして捕捉できる。しかし、これらは万能ではない。ネストされたオブジェクトの深い階層でのミューテーションや、forEach の内部に隠蔽された書き換えを完全に防ぐには、型定義を極めて厳格に、かつ再帰的に記述しなければならず、開発コストは跳ね上がる。
また、ESLintの eslint-plugin-react-hooks(バージョン7.1.1時点)も、万能の盾ではない。Math.random() や Date.now() は react-hooks/purity ルールによって検知されるものの、new Date() や外部モジュールの呼び出し(logger.info)、さらには forEach 内での代入や sort() は見事にスルーされてしまう。呼び出し先の関数が純粋であるかどうかを、現在の静的解析ツールが完全に判定することは不可能なのだ。
我々エンジニアが直面しているのは、「ツールが何とかしてくれる」という甘えが許されない時代への突入である。React Compilerの導入は、単なるビルドプロセスの追加ではない。それは、コードベース全体の「純粋性」に対する厳格な誓約を意味する。明日から我々が取るべき具体的な処方箋は、まず既存のコードベースに対して Readonly 型を徹底的に適用し、ミューテーションの余地を徹底的に排除すること。そして、レンダーフェーズにおけるあらゆる副作用(ログ出力、グローバル変数の書き換え、非冪等なAPIの呼び出し)を useEffect やイベントハンドラへ厳格に隔離することだ。
ここで私は、コミュニティに痛烈な問いを投げかけたい。我々は、チーム全員が「Rules of React」を1行の例外もなく遵守し続けられると、本当に信じられるだろうか? 開発スピードの要求と、複雑化するビジネスロジックの狭間で、この「純粋性の規律」を維持し続けることは可能なのか。それとも、React Compilerという恩恵を享受するために、我々は開発者の認知負荷をさらに高めるという、本末転倒なトレードオフを受け入れるべきなのだろうか。


コメント