フォトシンス エンジニアブログ

株式会社Photosynth のテックブログです

Nuxt2/Vue2とReact v19が混在しても壊さない!段階移行のルールとVue→React Bridge

この記事は Akerun - Qiita Advent Calendar 2025 - Qiita の22日目の記事です。

こんにちは。@ps-shimizuです。バックエンドシステムの開発やプロジェクトマネージャーを担当しています。

先日、業務でVue.js / Nuxt.js を採用しているSPAのフロントエンド移行に取り組む機会がありました。 本記事ではSPA (Client-Side Rendering) を前提に、Nuxt2/Vue2とReact v19が混在しても壊れないようにするためのルール設計とVue→React Bridgeの運用ポイントをお話しします。

はじめに

Vue -> React.jsへの移行のきっかけはシンプルで、Vue.js / Nuxt.js のVer 2系がEOLを迎え、保守 (サポート・セキュリティ) の前提が崩れたためです。

「今のまま動いているからOK」では済まなくなりどこかのタイミングで移行が必要となるのですが、 問題は移行の間も機能開発と改善を止められないことです。

一気に作り直せるならそれがシンプルですが実際には機能追加や改善を止められないことが多く、移行は大抵既存開発と並走になります。

そこで「既存のVue (Nuxt) を動かしたまま、Reactコンポーネントを差し込んで置き換える」段階移行が選択肢に上がります。 便利な反面、無計画に始めるとReactがVueと依存してしまい、最後にVue/Nuxtを削除できない状態になることが予想されます。

この記事では、SPA 前提で、Vue/Nuxt と React が混在する期間を壊さずに進めるために、先に決めたこと (ルール・置き場所・Bridge運用) をまとめます。

留意事項

  • 本記事の対象はSPA (Client-Side Rendering)です。
  • 本記事で紹介する VueにReactをマウントして段階移行するアプローチは先行事例を参考にしています (「参考」セクション参照) 。
  • 記事内のコードやディレクトリ例は一般化して記載します。
  • 最終ゴールはVue.js / Nuxt.js を完全に削除して React.js に移行し切ることです。本記事は「移行途中を安全に成立させるための設計と運用」の記事です。

技術スタック

  • 既存: Nuxt.js 2 / Vue.js 2 / Vuetify 2
  • 移行先: React v19 / TypeScript

※ここでは読者のイメージ用に最低限だけ記載。細かい構成や設定値は伏せます。

検討した選択肢と、React + TypeScript を採用した理由

段階移行に入る前に、選択肢は複数ありました。

  • Vue/Vuetify/Nuxtをサポートバージョンまでアップグレードする
    • 既存資産を活かしやすい一方で、破壊的変更が多いと結局「広範囲の修正+検証」になりますし、ビッグバンリリースになりやすい
  • 別スタックで一から作り直す
    • 理想的だが、機能差分の埋め戻し・移行期間の二重運用が重くなりやすい
  • 既存を動かしたまま、段階的に置き換える(段階移行)
    • 日々の開発と並走しやすい反面、混在期間の設計を誤るとトラブルが発生しやすい

段階移行のメリット

段階移行を選ぶメリットは、「移行のためにプロダクト開発を止めない」以外にもいくつかあります。

  • リスクを分割できる
    • 置き換え対象を小さく切れるので、影響範囲が読みやすく切り戻しもしやすい
  • 学習コストを分散できる
    • いきなり全員が新スタックを習得する必要がなく、移行の進捗に合わせてチームの習熟を進められる
  • 検証コストを局所化できる
    • 「全部動くか」ではなく「この範囲が同じ体験を維持できるか」に検証を寄せられる
  • 移行の優先順位を柔軟に変えられる
    • 影響の小さい領域から進めたりボトルネックになっているUIだけ先に置き換える、といった調整が可能
  • 方針転換に強い (段階移行が頓挫しても資産を持ち運べる)
    • もし段階移行がリスク/課題で進めづらくなり「別スタックで一から作り直す」方針に切り替える場合でも、段階移行で作ったReactコンポーネントはほとんどが流用可能

React + Typescript を採用した理由

最終的に React + TypeScript を軸にしたのは、技術的優劣というより 学習コスト・採用・育成 の影響が大きかったです。

  • すでに React/TypeScript を使っているチーム・プロダクトが存在し、知見と人材が厚い
  • 新しいスタックを増やすより、社内の強みを使って 移行リスクと学習コストを下げられる
  • 長期的にも採用・育成・横断支援がしやすい

段階移行の全体像

ゴールは明確で「Vue/Nuxt を削除し、React を「正」にする」です。

ただし、段階移行では「移行途中」が危険です。経験上、よくありそうな失敗は次の3つかと思います。

  • 失敗1: 例外が増えて収拾がつかない
    • 「今だけ」「この画面だけ」の例外が積み上がり、境界が崩れる
  • 失敗2: ReactがVueの実装詳細に依存して、最後に消せない
    • React側がVueのstoreやプラグインに直接依存し始めると撤去が難しくなる
  • 失敗3: 置き場所が曖昧で、同じ概念が複数箇所に生える
    • 修正のたびに「どっちが正?」が発生する

この失敗を避けるために、コードを書く前に次の2つを先に決めました。

  • ディレクトリ戦略: どこに何を置くか
  • ルール: 依存方向と例外の運用

そして、どうしても必要になるのが Vue→React Bridge (Vue上にReactをマウントする仕組み) です。

ディレクトリ戦略

今回の移行をきっかけにディレクトリ構造のルールを明文化し、置き場所を固定しました。 混在期間に効果を実感したのは、この「置き場所の固定」です。

  • src/pages
    • Nuxtのページ置き場 (Vueの既存ページが中心)
    • 移行途中は一部にReactページが混在し得るが、ページとしての責務 (ルーティング・つなぎ込み) を固定する
  • src/app
    • React専用のページ/アプリ構造の置き場 (React側の“正”をここに作る)
  • src/shared
    • React専用の共用コンポーネント置き場
    • 複数のReactページ/機能で再利用するUIやスタイルなどを集約する

ここで重要なのは、「sharedは便利だから何でも置く」にならないことです。 sharedが何でも置ける場所になってしまうと、結局あとで分離できず、移行の最終盤で苦労することになります。

運用上は、置き場所の判断を以下のようにしました。

  • Reactのページ/アプリ構造として置くなら src/app
  • Reactの共用コンポーネントとして使い回すなら src/shared
  • Nuxtのページとしての責務 (ルーティング・画面のつなぎ込み) が中心なら src/pages, src/components

最後にVue/Nuxtを消すために依存方向のルールを決める

段階移行で最重要なのは依存方向です。原則は以下の一つです。

  • 旧 (Vue) →新 (React) は許容
  • 新 (React) →旧 (Vue) は原則禁止

このルールを破ると、移行の後半で「Reactを進めるほどVueが必要になる」状態となってしまい「移行が進むほど撤去が難しくなる」という最悪の形です。

例外を許すなら「期限」「撤去条件」「オーナー」をセットにする

現実には、どうしても例外が必要な瞬間がありますが、 そのときに「例外OK」で終わらせると例外が永続化します。

例外を許可する場合は、最低限以下をセットとしました。

  • 期限: いつまでに消すか
  • 撤去条件: 何が揃ったら消せるか
  • オーナー: 誰が最後まで責任を持つか

PRレビューのチェックリスト

  • 置き場所は妥当か (src/app/pages, src/shared のどこか)
  • 依存が逆流していないか (React→Vueになっていないか)
  • 移行後にも残る設計か (Vue->React.js移行の都合がReact本体に入り込んでいないか)

Vue→React Bridge (VueにReactをマウントして進める)

段階移行を「動く形」にするのが Bridge です。 考え方はシンプルで、Vue側に「Reactを描画するための器」を用意します。

最小構成のイメージ ReactWrapper

先行事例と同様、Vueコンポーネントmounted でReactをマウントし、$attrs をReactのpropsとして渡します。

例:

<template>
  <div ref="container" />
</template>

<script>
import { createElement } from 'react'
import { createRoot } from 'react-dom/client'

export default {
  inheritAttrs: false,
  props: { component: { type: Function, required: true } },
  data() { return { reactRoot: null } },
  mounted() {
    this.reactRoot = createRoot(this.$refs.container)
    this.reactRoot.render(createElement(this.component, this.$attrs))
  },
  watch: {
    $attrs: { deep: true, handler() {
      this.reactRoot.render(createElement(this.component, this.$attrs))
    }},
  },
  destroyed() { this.reactRoot?.unmount() },
}
</script>

ここまでなら「Vueのコンポーネントの中にReactが出る」だけですが、 本当に難しいのは運用で、次の2つがハマりどころになります。

ハマりどころ1 ルーティングと <a> の扱い

BridgeでマウントしたReact側が <a> で遷移すると、Vue側のルーティングに乗らず 全リロード になることがあります。 SPAでは体感が悪く、さらに「どの遷移が安全か」が曖昧になると事故が増えます。

回避としてはReact側に「遷移関数」をpropsで渡し、リンクはそれ経由で行うなどです。

詳細は先行事例がわかりやすいので、参考資料のリンク先をご覧ください。

ハマりどころ2 slot/children をどう渡すか

Vueのslot (子要素) をReactのchildrenとして渡したくなる場面があります。 ただ、Vueのslotは「ただのHTML」ではなくVue独自の表現です。

ここを雑にやると、React側にVueの都合が漏れてBridgeのボリュームが膨らみます。 先行事例では「静的HTML/文字列に制限する」「HTMLをJSXに変換する」などの工夫が紹介されていますが、どちらもトレードオフです。

段階移行を安全に回す観点では、最初から欲張らずに

  • childrenを必要としないコンポーネントから置き換える
  • childrenが必要な場合も、まずは静的な表現に制限する

のように「進め方で回避する」ほうが、結果として移行が止まりにくい印象でした。

Bridgeのボリュームを増やさないための運用ルール

Bridgeは便利なので何でも運べてしまうため、撤去時の作業ボリュームが膨らむのでBridge運用は次の方針としました。

  • propsは薄く (primitive中心)
  • 関数propsは最小限 (遷移など必要なものだけ)
  • Vue側の実装詳細 (store/プラグイン等) をReactへ漏らさない

置き換え順序

どこから置き換えるかで移行の難易度は変わりますので、大雑把には影響の小さいところから始めるのが安全です。

  • 影響が限定される領域 (例: ログイン後)
  • 画面遷移を伴わないUI (例: モーダル)
  • 影響が大きい領域 (公開・高トラフィック) は後回し

ここは「技術的にできるか」より「トラブルが発生した際に戻せるか」「ユーザー影響がどれくらいか」で優先順位を決めるほうが、結果として止まりにくい印象です。

テスト方針 (テスティングトロフィーに基づくテスト設計)

段階移行では「境界」が壊れやすいので、テストは闇雲に増やすのではなく、スティングトロフィーの考え方に寄せて設計しました。

テスト戦略の優先順位

        /\
       /  \  E2E (少数)
      /----\
     /      \  統合テスト (適度)
    /--------\
   /          \  機能テスト (重点) ← ここに注力
  /------------\
 /              \  単体テスト (実装詳細は除外)

React側のテスト実装には React Testing Library を利用していますが、方針としては「何をテストすべきか」をまず固定します。

機能面にフォーカス

  • テストすべきこと
  • テストすべきでないこと
    • CSSクラス名の付与確認
    • コンポーネントの内部状態 (外部から見えない部分)
    • 実装の詳細 (具体的なDOM構造など)
    • スタイリングの詳細

この前提を置いた上で、Bridgeを挟む境界 (props、遷移、イベント、エラー時) を優先して守るようにしています。

最後にVue/Nuxtを消すために

最終ゴールが「Vue/Nuxtを完全に削除する」以上、後半で撤去コストが膨らまないように、いまの時点で次を方針として決めています。

  • React側の「正」を src/app に寄せ続ける
  • 依存逆流を例外扱いにしない (例外は管理する)
  • Bridge撤去の完了条件 (チェック項目) を先に決めておく
    • Bridge経由の呼び出し箇所が0
    • 新旧依存が逆流していない
    • 代替のReact実装が揃っている

Bridgeは便利ですが最終的には消す対象です。

消せる形に保つためにディレクトリ戦略とルールを先に固定し、混在期間中も崩さないことが大切だと思っています。

まとめ

  • 段階移行の本質は「VueにReactをマウントする」ことより、最後にVue/Nuxtを削除できる設計を保つこと
  • そのために、まず ディレクトリとルールを決める
  • Vue→React Bridge は必要悪になりやすいので、「propsを薄く、関数を最小限、実装詳細を漏らさない」が重要

段階移行は「動くものを増やす」ほど、あとから保守対象のコード範囲も増えていきます。だからこそ実装前にディレクトリ戦略と依存ルールを先に決めておくと、混在期間中の迷いが減り、手戻りや撤去コストを小さく保ったまま移行を進めやすくなります。

参考

note.com

medium.com


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 photosynth.co.jp

Akerunにご興味のある方はこちらから akerun.com