SardineCon SF/2026

Learn More
Engineering
Engineering

本番環境での高リスク運用向け React 18 移行ガイド

Brendon Matos
Brendon Matos
8 min read
bg-image
bg-image
重要な本番環境のための React 18 移行ガイド
SUBSCRIBE
Share

舞台設定:高いリスクと大規模なスケール

Sardine では、ダッシュボードが不正検知およびコンプライアンスプラットフォームの主要なインターフェースとなっています。コンプライアンスアナリスト、リスクオペレーター、エンジニアリングチームは、取引の調査、ルールの管理、アラートの監視のために、毎日このダッシュボードを利用しています。これは、数百のコンポーネントと数十の内部パッケージを含み、バックエンドサービスと深く統合された、大規模な React と TypeScript のモノレポです。

React 18 では、自動バッチ処理、コンカレント機能、そして最新化されたレンダリング API など、実用的な改善がもたらされました。しかし、私たちの規模の本番アプリケーションにとっては、単なるバージョン番号の更新以上の対応が必要でした。

React 17 と 18 の間の挙動の違いは、かなり微妙で、自動テストや手動の QA をすり抜けてしまう程度のものです。多くの場合、本番環境で特定のデータ条件がそろったときにだけ表面化します。たとえば、無限再レンダー ループが原因で画面がフリーズした、と顧客から報告があったときなどです。こうした問題は見つけるのが難しく、事後的に修正するのはさらに困難です。この現実が、私たちの戦略を形作りました。つまり、実際のトラフィックを使って段階的に変更を検証しつつ、いつでも即座に元に戻せるアプローチが必要だったのです。大きな PR を 1 つ用意して専用のバグバッシュを行う案も検討しましたが、モノリシックなアップグレードは、一度問題が出たときにロールバックしづらく、原因を二分探索的に切り分けるのも難しく、時間的なプレッシャーの中で修正するのも困難です。

この投稿では、これらの課題を解決するために構築したアーキテクチャについて説明します。具体的には、インポートマップを利用した、フィーチャーフラグ付きのユーザーごとの React バージョン切り替え機構です。また、ロールアウト中に判明した挙動上の問題を解決するために作成した codemodscustom ESLint ルールについても紹介します。

注: 本記事中のコードスニペットは、わかりやすさのために簡略化されており、実際の本番環境での実装そのものを表しているわけではありません。

技術的な課題:微妙な変化と気づきにくい不具合

React 18 は、紙の上では単純に見えるものの、大規模なコードベースでは予測しづらい挙動をもたらす変更を導入しました。私たちの移行作業の中で、特に目立った問題は主に 2 つありました。

自動バッチ処理により、状態更新がフラッシュされるタイミングが変わる

React 17 では、Promise やタイマーの中で行われた更新は、setState が呼ばれるたびに再レンダーが発生していました。React 18 では、これらが 1 回の処理にまとめてバッチされます。通常はパフォーマンス向上になりますが、setState 呼び出しの直後に DOM や ref を読み取り、「途中のレンダー」が終わっていることを前提にしているコードは、レンダーがまだフラッシュされていないために「古い」データを見ることになります。これはサイレントに失敗し、最終的な UI は正しく見えるものの、その間に実行されるロジックが壊れてしまいます。

より厳密な参照の同一性チェックによって、不安定な参照が明らかになる

React 18 の再調整処理は、props が変化していない場合に再レンダーを打ち切る判断が、以前よりも積極的になっています。これは正しい挙動ですが、React 17 では暗黙的に許容されていたある種のバグを表面化させます。つまり、インラインの []{}、および () => {} をデフォルト値として使うと、レンダーのたびに新しい参照が生成されてしまうという問題です。これにより、その値が useEffectuseMemo、あるいは参照の同一性に依存する @tanstack/react-table のようなライブラリで消費された場合、無限再レンダーが発生してしまいます。

これらの問題を出荷前に完全に洗い出すのは、ほぼ不可能だと分かっていました。だからこそ、これは単なるバージョンアップとは捉えず、問題を段階的に発見して修正できるように設計されたインフラを伴う、制御されたロールアウトとして扱ったのです。

注意: 私たちのダッシュボードはサーバーサイドレンダリングを一切行わない純粋なSPAであるため、React 18で強化されたハイドレーション不一致チェックの影響を完全に回避できました(もしあなたのアプリケーションがSSRを使っているなら、移行時に大量のハイドレーションエラーが発生することを覚悟してください)。

最適な道の選び方:コードモッドからインフラレベルの切り替えまで

独自のソリューションを構築する前に、既存のツールやアーキテクチャを評価し、最も安全なロールアウト方法を検討しました。

公式移行ツール

React チームは react-codemod という自動移行スクリプトのセットを提供しています。私たちは、非推奨になったライフサイクルメソッドの更新、型定義の移行(React.VFC を削除して React.FC に置き換え、その際に暗黙的な children も削除し、render の戻り値の型も更新)、さらに新しい JSX トランスフォーム向けの update-react-imports codemod の適用など、該当する変換を実行しました。これらによって機械的な API レベルの変更が処理され、移行の第一段階として不可欠な作業が完了しました。

しかし、codemod が修正できるのは、構文的に特定できるものだけです。挙動に関する問題(不安定な参照や変更のバッチ処理など)は構文の問題ではありません。それらは、コンポーネント同士やデータとのやり取りに依存する実行時の問題です。どんな codemod でも、特定の props の組み合わせであなたの useEffect が無限ループに陥ることを検出することはできません。そのためには、新しいバージョンを実際のトラフィックに対して動かしてみる方法が必要でした。

バージョン切り替え戦略

私たちは3つのアプローチを検討し、それらを以下の表にまとめました。

アプローチ

長所

短所

インポートマップ + CDN

リビルドは不要で、即時ロールバックとユーザー単位のきめ細かなターゲティングが可能になります。

CDN/静的ESMの依存関係が追加され、Reactの読み込み時にわずかなレイテンシーの増加が発生します。

二重の npm エイリアス

CDN に依存せず、完全に単体で動作します。

2種類のビルド成果物が必要になり、バンドルサイズが大きくなるうえ、インフラ面で大きなオーバーヘッドが発生します。

Kubernetes ベース (Airbnb のアプローチ)

完全なプロセス分離を提供します。

大規模なインフラ要件が必要で、私たちの環境にはオーバースペックです。

インポートマップは、インフラのオーバーヘッドを最小限に抑えつつ、最も柔軟性を確保できる手段でした。React のバージョン切り替えも、サーバー側で文字列を置き換えるだけで済みます。再デプロイも、別個のビルドパイプラインも、追加のコンテナも不要です。唯一の注意点はブラウザー対応状況で、インポートマップは比較的新しい Web 標準であることです。このアプローチを本採用する前に、分析データをCan I useと突き合わせて確認し、アクティブユーザーの 100% が完全対応しているブラウザーを利用していることを確認しました。私たちのプロダクトはコンプライアンスやリスク管理チームに使われる B2B 製品であり、利用されるブラウザー環境は予測しやすく常に最新に近いため、この選択は安全だと判断できました。

__wf_reserved_inherit

アーキテクチャ:リモートライブラリのアセットバージョン切り替え

本番ビルドでは、React をアプリケーションに同梱する代わりに外部化しています。サーバーはフィーチャーフラグを確認し、適切な import mapindex.html に挿入してから配信します。

インポートマップはサーバー側で生成されるため、環境ごとにアセットの取得元をスムーズに制御できます。必要であれば、コードを一切変更せずに、自前の S3 バケットから React を配信することも可能です。

__wf_reserved_inherit

まず、最終的なバンドルに含めないように、React を外部化しました。

開発環境では、Vite が React 18 を直接バンドルするため、エンジニア全員が通常のワークフローの中で常にターゲットバージョンを使って作業できます。

サーバーサイドのロジック:

HTML テンプレートには、インポートマップで置き換えられるプレースホルダーが含まれています。

そうすることで、ブラウザエンジンはすべてのimport React from 'react'をネイティブに解決し、CDN を呼び出します。

デュアルレンダリングのエントリーポイント

アプリケーションのエントリーポイントは、実行時にどの React API が利用可能かを検出します。

段階的なロールアウト戦略

__wf_reserved_inherit

ロールアウトは5つのフェーズで行われ、それぞれが機能フラグによって制御されていました。

フェーズ0:マージ前

すべてのマイグレーションコードは、フラグがデフォルトでオフの状態で main ブランチにマージされました。本番環境は、変更を一切加えることなく、引き続き React 17 で稼働していました。

フェーズ1:開発

エンジニアたちは、日々の作業をローカル環境で React 18 を使って実行していました(Vite は開発モードでは常に React 18 をバンドルします)。これにより、通常の開発フローの中で問題を早期に発見できました。

フェーズ2:サンドボックス

私たちのサンドボックス環境には2つの目的があります。お客様が当社プラットフォームを評価するために利用すること、そして本番環境にリリースする前に、すべてのリリースがまずここにデプロイされることです。私たちは最初にこの環境でフラグを有効化しました。これにより、数週間分の実際の利用パターンを得ることができました。お客様は本番環境に一切リスクを与えることなく、自社の評価ワークフローを実行できたのです。

フェーズ3:社内ユーザー

本番環境では、Sardine の従業員向けにフラグを有効化しました。機能フラグはユーザー単位および組織単位でのターゲティングに対応しているため、まずはエンジニアリングチーム、その次に会社全体へと、ロールアウトの範囲を正確に絞り込むことができました。これにより、お客様が変更に気づく前に、実データを使って重要なワークフローを検証することができました。

フェーズ4:段階的なロールアウト

外部ユーザーへのパーセンテージベースの段階的リリースで、5%から100%まで徐々に拡大します。React のバージョンは各ページ読み込み時にサーバー側で決定されるため、無効化すべきキャッシュ状態は存在しません。フラグを切り替えれば、次のリクエストでは以前のバージョンが返されます。

判明したこと:大規模環境における不安定な参照

ローカル環境とサンドボックスで社内ユーザーへのロールアウトが進むと、無限再レンダーのループが発生し始めました。React 17 では問題なく動作していたコンポーネントが、React 18 ではブラウザをフリーズさせるようになったのです。根本原因はいつも同じ種類の問題、つまり不安定な参照でした。

このパターンの簡略化した例は次のとおりです。

このフックが実行されるたびに、フォールバックの []() => {} は新しいオブジェクト参照を生成します。これらの値に依存する下流の useEffectuseMemo が再実行され、それが別のレンダーを引き起こし、さらに新しい参照が作られ…ということを繰り返して無限ループに陥ります。

React 17 では、再調整処理における参照チェックがそれほど厳密ではなかったため、この問題をある程度許容していました。React 18 では、開発環境での Strict Mode による副作用の二重実行や、Concurrent Rendering による更新スケジューリングの違いによって、こうしたバグがより積極的に表面化するようになりました。React 18 は正しい動作をしているのですが、その「正しさ」によって、長年積み重なってきた近道や妥協が一気に露呈した形になったのです。

安定した定数

この修正は、一連の固定されたシングルトン参照です。

適用すると、次のように表示されます。

これは単純なコンセプトでした。難しかったのは、それを何百ものファイルに一貫して適用することでした。そして、これらの問題が最終的なロールアウトを妨げていたため、私たちは迅速に対応する必要がありました。

自動変換のためのコードモッド

私たちは2つのjscodeshift のコードモッドを作成し、繰り返し作業を自動化しました。

fix-unstable-hook-returns は、use[A-Z]* にマッチするすべての関数を走査し、戻り値に []{}、または () => {} を含むものを、対応する安定した定数に置き換えます。

fix-unstable-destructuring-defaultsは分割代入のこのバリエーションを検出します:

どちらのコードモッドもインポートを自動的に処理し、必要に応じて @/react-migration-support のインポートを追加または拡張します。

最終的に、すべてのコードモッドを実行し終えた時点で、1000件以上のファイルが変更されていました

リグレッションを防ぐためのカスタム ESLint ルール

既存のコードベースを修正することは必要でしたが、複数フェーズにわたるロールアウトの間にリグレッションを防ぐことも同じくらい重要でした。これらのパターンを自動的に強制し、移行の長期的な安定性を確保するために、私たちは 5 つのカスタム ESLint ルールを作成しました。

ルール

検出できるもの

no-unstable-default-props

インラインリテラルをコンポーネントのデフォルトプロップとして使う。

no-unstable-hook-return

カスタムフックからの不安定な戻り値

no-unstable-destructuring-defaults

const { data = [] } = useHook() パターンです。

no-unstable-react-table-props

メモ化されていないデータ/カラム(列)がuseReactTable 内にあります

no-unmemoized-array-transform

.map() / .filter() の結果が直接レンダー内で使用されています。

このno-unstable-react-table-propsルールは特に重要です。@tanstack/react-tableは内部的に参照の同一性を利用しています。data や columns がメモ化されていないと、親コンポーネントが更新されるたびにテーブルが再レンダーされてしまいます。このルールはuseMemouseStateの宣言を追跡し、メモ化されていない値がuseReactTableに渡されている場合に警告します:

これらのルールは、移行が終わった後も長く役に立ちます。これらが強制するパターンは、React のバージョンに関係なく良いプラクティスであり、将来 React 19 に移行するときにも、同じ種類の問題を検出してくれます。

最後に:大規模マイグレーションのための青写真

  • バージョン移行において、Import Maps は過小評価されています。Import Maps を使うことで、追加のビルド基盤を用意せずに、ユーザーごとのバージョン切り替えを実現できました。外部 CDN への依存はありますが、jsDelivr のような信頼できるプロバイダを利用したり、キャッシュ用プロキシを挟んだりすることで十分に管理可能です。
  • 修正だけでなく、予防に投資しよう。コードモッドは既存コードを一度に修正するうえで不可欠でしたが、カスタム ESLint ルールは、同じ問題パターンが二度と戻ってこないようにしてくれます。これらのルールの作成には手間がかかりましたが、コミット前にエラーを検知してくれるたびに、そのコストは回収できます。なぜなら、それは本番環境でのバグ報告として発覚するのを防いでくれるからです。
  • フィーチャーフラグによって、リスクの高いアップグレードを日常的なデプロイ作業へと変えることができます。特定のユーザー、組織、あるいはトラフィックの一定割合を対象にできる機能によって、ロールアウトを完全にコントロールできました。このきめ細かさと、即座にロールバックできる機能が組み合わさることで、大きなメジャーバージョンアップに付きものだった従来の時間的なプレッシャーはなくなりました。
  • 本番環境の問題に、安全かつ時間的なプレッシャーなしで対処する。 私たちが発見した不安定な参照の問題は、単体テストだけではほぼ見つけることが不可能だったでしょう。インポートマップのアーキテクチャによって、本番のトラフィックとデータを使いながら、即時リバートという安全網に守られた状態で、こうしたレアケースを安全に検出できる環境を実現できました。

これからを見据えて:React 19以降にも通用する備え

import map アーキテクチャの優れている点は、バージョンにまったく依存しないことです。React 19 が登場しても、同じ基盤によって、同じ精度と安全性でロールアウトを行うことができます。

私たちが構築したパターン――コアな依存関係を外部化し、import map を使ってバージョンを切り替え、さらにフィーチャーフラグで移行を制御する――は、React に特有のものではありません。これは、ミッションクリティカルな環境において、あらゆる主要ライブラリのアップグレードに対して、再現可能で、元に戻すこともでき、かつ段階的に展開していける手法を提供します。