GyazoのReactのバージョンをv17からv19にアップグレードし、React Compilerを有効にしました

Gyazo開発チームでアプリケーションエンジニアをしているid:Pasta-Kです。先日Gyazoで使っているReactのバージョンが19.2.4になり、そしてReact Compilerも導入されました。現代ですね。この記事では、Gyazoで利用しているReactをv17からv18、さらにv19へとアップグレードした際の取り組みについて紹介しようと思います。

(この記事はGithub Copilotが過去のgit logやPull Requestなどをもとに書いてくれた文章を加筆修正したものです)

facebook/fluxからの脱却

React v18へのアップグレードを行う前に、facebook/fluxを脱却することにしました。GyazoのReactのコードは10年以上に渡り利用されていて、このfluxを用いたアーキテクチャも10年前の当時に導入されたものから脱却できずにいました。勿論皆さんご存知の通り、facebook/fluxは現在もうメンテナンスはされていませんし、依存としてもReact v18以降に対応していないということもあり現実問題として向き合うときがやってきてしまいました。以下のような問題点や現代に置いてはReact自体のライフサイクルなども当時と変わっていることも踏まえて移行先の検討を始めました。

移行先のアーキテクチャについて

結論を書くと、Fluxの代わりにコアの戦略としてSWRを採用することとしました。

そもそもの課題感として、これまでフロントエンドに存在するAPIクライアントを介してFetchで取得しつつ、FluxでStoreを楽観的な更新をした上でStoreにデータを足すということをしていたのですが、それらのデータの更新がAPIから返ってくるものを変形させている箇所とさせていない箇所があったり、また楽観的更新だけで画面が更新されていてサーバーサイドにデータが同期されていることを確認していない箇所があったりと大変なことになっていました。

各種リソースやデータの信じられる状態がどこにあるかを明確にした方が良いだろうということで、サーバーサイドから降ってくる情報をもっと頻繁に取得して確認することでGyazoではユーザーが扱う主なリソースは画像に関するもののみで、他にはユーザー情報などもありますが、大きく考えると取り扱うリソースの種類が少ないことから、要件を満たすことが出来るのではないかと考えました。これまでの問題として連続で画像をクライアントアプリケーションからアップロードした際の一覧の再取得と構築などにも問題を抱えていたのですが、SWRはライブラリとしてフォーカスが当たったときの検証などを通してデータの再取得をサポートしてくれているので、移行すればこの問題もシンプルに解決出来ると考え、SWRを採用してリソース毎にrevalidateやmutateを行って、基本的には楽観的更新などをせず更新中と分かる表示をすることで、データの一貫性を保証することを目指しました。

FluxのStoreをSWRを同期させて漸進的な移行を

Fluxからの脱却の準備で最も効果的だったのが、FluxのStoreとSWRを共存させながら段階的に移行する仕組みです。

一度に全てのFluxのStoreを削除するのは現実的ではありません。そこで、SWRで取得したデータを既存のFluxのStoreに同期するsyncWithStoreというミドルウェアを作成することで、一時的にFluxとSWRで同時に同じリソースを扱えるような仕組みを用意しておきました。

FluxのStore側に以下のようなインターフェイスを持たせておいて、

interface SyncableStore {
  setEmitter: (emitter: EventEmitter) => void;
  syncFromSWR: (data: unknown) => void;
}

setEmitterでEventEmitterを渡しておいて、Flux側でデータ更新があった際はこのEmitterを使ってSWRのmutateを呼び出し、SWR側でmutateが走った際などデータが取得されたときにはStoreのメソッドである syncFromSWR に値を渡してStoreを更新するという形で、SWRとFlux間での同期を行えるようにしました。

export const syncWithStore: (store: SyncableStore) => Middleware =
  (store) => (useSWRNext: SWRHook) => {
    return (key, fetcher, config) => {
      const swr = useSWRNext(key, fetcher, config);
      const emitter = useEmitter();
      const data = swr.data;
      
      useEffect(() => {
        store.setEmitter(emitter);
      }, [emitter]);
      
      useEffect(() => {
        store.syncFromSWR(data);
      }, [data]);

      return swr;
    };
  };

この仕組みにより:

  1. SWRでデータを取得 → 自動的に既存のFlux Storeにも反映
  2. 既存のコンポーネントは変更不要 → Storeから読み取るコードはそのまま動作
  3. 段階的な移行が可能 → コンポーネントごとにStore経由からuseSWRを使った呼び出しへ移行が可能に

ここでSWRが利用していたEmitterは後にリソースに関連するSWRのキャッシュをまとめて再取得することにも利用されることになりました。

漸進的移行の効果

syncWithStoreの導入により、以下のような段階的な移行が可能になりました:

フェーズ1: SWRとStoreを並行稼働

// データ取得はSWRに移行、既存コードはそのまま
useSWR(key, fetcher, { use: [syncWithStore(Store)] });

フェーズ2: 新しいコンポーネントはSWRを直接参照

// 新規コンポーネントはSWRのdataを直接使用
const { data } = useSWR(key, fetcher);

フェーズ3: 既存コンポーネントをSWR参照に書き換え

// Store.get()からuseSWRに移行
- const image = CurrentImageStore.get();
+ const { data: image } = useCurrentImage(); // この内部でuseSWRを利用している

フェーズ4: Storeを完全削除

// syncWithStoreを削除し、Storeファイルを削除

この4段階のアプローチにより、大きな破壊的変更なしにFluxから脱却できました。

HOCによるClass ComponentとHooksの橋渡し

またGyazoのコードベースには歴史的経緯でClass Componentのまま現存している(まぁまぁ巨大な)Componentがいくつか存在しています。Fluxからの脱却に伴い、HooksベースのContext APIやSWRを導入しましたが、既存のClass ComponentでHooksを直接使用できないという課題に直面しました。このために多数のClass Componentが残っており、これらを一度に書き換えるのは現実的ではありません。

そこで、Higher-Order Component(HOC)を使って、Class ComponentにHooksの機能を注入する仕組みを導入しました。

HOCでHooksをPropsとして注入する

HOCを使うことで、Hooksの結果をPropsとしてClass Componentに渡せます:

export type WithProcheckoutStatesProps = {
  proCheckoutStates: ProCheckoutStates;
};

export const WithProcheckoutStates = <
  T extends WithProcheckoutStatesProps = WithProcheckoutStatesProps,
>(
  Component: React.ComponentType<T>,
) => {
  const ComponentWithProcheckoutStates = (
    props: Omit<T, keyof WithProcheckoutStatesProps>,
  ) => {
    // Function ComponentなのでHooksが使える
    const proCheckoutStates = useProCheckoutStates();
    
    // Hooksの結果をPropsとして渡す
    return (
      <Component {...(props as T)} proCheckoutStates={proCheckoutStates} />
    );
  };
  
  ComponentWithProcheckoutStates.displayName = 
    `WithProcheckoutStates(${Component.displayName || Component.name})`;
  
  return ComponentWithProcheckoutStates;
};

このHOCを使うと、Class ComponentでもContext APIの値を受け取れます:

// Class Component
type Props = WithProcheckoutStatesProps & {
  // 元のComponentのProps
};

class ChangeTypes extends Component<Props, State> {
  render() {
    // PropsとしてContext APIの値が渡される
    const { proCheckoutStates } = this.props;
    
    return (
      <div>
        {proCheckoutStates.interval === 'yearly' ? '年払い' : '月払い'}
      </div>
    );
  }
}

// HOCでラップしてexport
export default WithProcheckoutStates(ChangeTypes);

Flux完全削除

これらの取り組みの甲斐があり、無事Fluxを削除することが出来るようになりました。めでたしめでたし。

react-bootstrapからHeadless UIへの移行

Flux脱却に先立って、古いreact-bootstrapに依存したデザインコンポーネントがあったので、それらをHeadless UIに移行したりもしていました。こちらは既存のコンポーネントを一覧にし、デザイナーと共に整理しながら、Headless UIにまとめていきました。ポップアップメニュー風のComponentが複数あったり、モーダルも特殊なデザインのモーダルのものがいくつかあることが発見されたので、それらを統廃合することを行いながら、Headless UIを用いた実装に差し替えることで脱出に成功しました。

React v17からv18へのアップグレード

これらの他にもいくつかのライブラリがv18以降に対応していなかったのですが、それぞれ新しいライブラリに差し替えたりすることで、React v18へのアップグレードの準備が整ったので、遂にアップグレードを実施しました。

ReactDOM.renderから createRoot への移行

React v18でもReactDOM.render を一応利用することが出来たので、こちらの移行はバージョンを上げた後に別途実施することとしました。localStorageの値を見てレンダラーを切り替える仕組みを用意して、開発者や社内のスタッフが本番環境などで createRoot を使ったレンダリングを試す期間を用意して、その期間に問題がないことが確認された後に、一般のユーザーの環境でも切り替えるというアプローチで移行を行いました。

React v18からv19へのアップグレード

段階的なアップグレード手順

ステップ1: TypeScript型定義の更新

React 19で非推奨となったTypeScriptの型定義を更新しました。codemodが提供されています。便利ですね。

npx codemod@latest react/19/preset ./client/js/

ステップ2: React v19へのアップグレード

この辺で準備が整ったので、v19にカシュッとアップグレードしました。

ステップ3: React 19の新機能への対応

React v19では<meta><title>タグがコンポーネント内で使用でき、自動的に<head>に配置されるようになったり、Context APIに変更が入っているので、ついでに追随しておきました。

Context API利用周りの改善は、追随するためのcodemodがあるのでそれぞれ実行しました。

# Context.Providerの削除
npx codemod@latest react/19/remove-context-provider

# useContextへの移行
npx codemod@latest react/19/use-context-hook

React Compilerの導入

せっかくReact 19へのアップグレードまでやってきたので、ベータが取れたReact Compilerも導入しました。

導入手順

  1. パッケージのインストール
{
  "devDependencies": {
    "react-compiler-webpack": "1.0.0"
  }
}
  1. Webpack設定の追加
// webpack.config.mjs
import {
  defineReactCompilerLoaderOption,
  reactCompilerLoader,
} from 'react-compiler-webpack';

module.exports = {
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        use: [
          // ... 既存のloader
          {
            loader: reactCompilerLoader,
            options: defineReactCompilerLoaderOption({
              // React Compiler options
            }),
          },
        ],
      },
    ],
  },
};

React Compilerにより、手動でのメモ化(useMemouseCallback)が不要になり、パフォーマンスの最適化が自動で行われるようになりました。めでたいですね。試しにいくつかメモ化の有無で性能を比較したりしましたが、ちゃんと効いていそうでした。めでたい!

というのも、事前にv18からのアップグレードに前後するような形でeslint-plugin-react-hooksをバージョンアップして、React Compiler向けの各種lintルールを有効化して問題箇所の洗い出しと修正を対応しておいたのでした。

まとめ

React v17からv19へのアップグレードは、以下のステップで実施しました:

  1. 準備段階:

    • react-bootstrapからの脱却
    • findDOMNodeの排除
    • その他負債の整理
    • Automatic Runtimeへの移行
  2. Flux脱却:

    • 機能単位での段階的な移行
    • Context API + SWR + EventEmitterへ
  3. v18へ:

    • Flux削除と同時実施
    • 依存関係の更新のみで完了
  4. v19へ:

    • 型定義の更新
    • Context APIの移行
    • React Compilerの導入

GyazoのReactのコードベースの歴史は既に10年以上に渡ります。その間に見て見ぬ振りをしてきていたライブラリの陳腐化やデータの取り回しの構造などについて見直しを行うキッカケにもなりました。結果的にコードベースも若干整理され、それらによりパフォーマンスやフロントエンドでのデータの取り扱いも安定するという恩恵を受けることが出来ました。React v18へのアップデートが2025年まで出来なかったのは心苦しい感じではありましたが、チームや上司の支援もあり、やり切ることが出来ました。良かったです。

一方でv18へのアップデートに際して、時間をかけてフロントエンド全体の方針や設計の見直しなどを行ったので時間は掛かってしまいましたが、それ以降で効果的にバージョンアップの恩恵を受ける子が出来るようにも出来たのではないかと思っています。また、React v19へはそもそもReact自体のメジャーバージョンアップ時の差分の大小はありますが、そのままの流れでアップデート出来たのは良かったです。また、これらの活動によりReactのバージョンアップへの理解やレンダリングへの影響を確認するE2Eテストなどが揃っていたこともあり、React Compilerもスムーズに恐れること無く有効にすることが出来ました。React Compilerはその導入により、パフォーマンスの最適化が自動化されているという恩恵は若干ですが日々感じられているので良い感じです。(特にレビュー時などにメモ化について気にしなくて良くなったのはいい話だと思います)

今後、React v17のアップグレードなどを検討している方の参考になれば幸いです!