TalentX Tech Blog

Tech Blog

準備で勝ちに行く!「Nuxt.js → React」移行戦略

MyTalent CRMでフロントエンド開発を行なっている田中です🙍‍♀️

現在、MyTalent CRM(以下CRM)では、フロントエンドのフレームワークを Nuxt.js から React へ移行しています。

Vue.js(Nuxt.js)を愛していた私としてはちょっぴり残念に思っていますが、こういった機会は滅多にないと思い、二つのVSCodeを色分けしてNuxt.jsとReactの反復横跳びを楽しんでいます🤸

Claude Codeを相棒に、このリプレイスプロジェクトを少しでも効率的に、そしてアプリケーションをより堅牢にするために最近行なったことをご紹介します。

Nuxt→Reactのしんどさ、正直に言うと

まず前提として、VueとReactは「似たようなもの」ではありません。
Vueがある程度の設計をフレームワーク側が担ってくれる思想なのに対し、Reactは「UIライブラリ」——つまり、ルーティングもステート管理も、ほぼ何も決まっていない状態からスタートします。(それぞれの設計思想や特徴についての説明は割愛します)

NuxtのDXに慣れていると、この「何も決まっていない感」が最初はかなり堪えました。
auto-importが効かない、useXxx の作法も微妙に違う……など小さなストレスがあったのも事実です🤒

ただ裏を返せば、設計を自分たちで決められるということでもあります。
どうせ移行するなら、この機会に「AIエージェントと協働しやすいコード設計」を最初から仕込もう、と考えたのがそもそものきっかけでした。

そしてCRMでは以下のような構成で走り出しました。

JS: React × TypeScript
DataFetch: TanStack Query
Routing: TanStack Router(file-based)
UI: styled-components

アーキテクチャは Bulletproof React を参考に構築しています。

準備①:スキーマ駆動開発 with openapi-typescript

openapi-ts.dev

リプレイス作業においてBulletproof Reactのつらいところのひとつは、すでにあるドメインを features/ に移していく作業が先に発生することです🫠

なるべく閉じた・簡易なページから移行作業に取り組んでいましたが、どんなページもやはりある程度は各featureのAPIフェッチャーやtypesを用意しておかないと初速が出ない、という課題がありました。

最初はClaude CodeをはじめとしたAIエージェントに、都度バックエンドのリポジトリにある api_spec.yaml を読み込んで、対応するfeatureの型やAPIのフェッチャー関数を生成してもらう、という運用をしていました。
ただこれを続けると、コンテキストの消費量や生成されたファイルの配置場所を管理しきれなくなってきます…

また、型のimportの場面でBulletproof Reactでは御法度とされているfeature間のimportが多発してしまい、物によっては循環参照が生まれてしまうこともあり、頭を悩ませる日々でした。

そこで目をつけたのが、OpenAPI仕様ファイルを開発の軸にするスキーマ駆動開発です。

openapi-typescript を使ってコマンド一つでバックエンドのリポジトリにある api_spec.yaml の情報をもとに型(スキーマ)を出力し、それをベースに開発していくスタイルがとてもあっていました。

以下のようなファイルがフロントエンドのリポジトリに作成され、このファイルをベースにエンドポイントやパスやクエリパラメータ、レスポンスの型を抽出していきます。

export type paths = {
  '/my-api': {
    parameters: {
      query?: never;
      header?: never;
      path?: never;
      cookie?: never;
    };
    get: {
      parameters: {
        query: {
          page: number;
          pageSize: number;
        };
        header?: never;
        path?: never;
        cookie?: never;
      };
      requestBody?: never;
      responses: {
        200: {
          headers: {
            [name: string]: unknown;
          };
          content: {
            'application/json': {
              totalCount?: number;
              automations?: components['schemas']['MyApi'][];
            };
          };
        };
      };
    };
// ...

生成されたスキーマから下記のような形で必要な型を取り出すことができます。

import type { paths, components } from "./my-openapi-3-schema"; // openapi-typescript によって生成

// スキーマオブジェクト
type MyType = components["schemas"]["MyType"];

// パスパラメータ
type EndpointParams = paths["/my/endpoint"]["parameters"];

// レスポンスオブジェクト
type SuccessResponse =
  paths["/my/endpoint"]["get"]["responses"][200]["content"]["application/json"]["schema"];
type ErrorResponse =
  paths["/my/endpoint"]["get"]["responses"][500]["content"]["application/json"]["schema"];

上記はOpenapi-tsでも紹介されている方法ですが、まだ使い勝手がイマイチなので、少し型パズルをして直感的に扱えるように整えます。

import type { components, paths } from './schema';
import type { Get } from 'type-fest';

export type ApiPath = keyof paths;

export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';

export type GetContent<
  Path extends ApiPath,
  Method extends HttpMethod,
  Code extends number,
> = Get<paths, `${Path}.${Method}.responses.${Code}.content.application/json`>;

export type ApiPathParams<
  Path extends ApiPath,
  Method extends HttpMethod,
> = Get<paths, `${Path}.${Method}.parameters.path`>;

export type ApiQueryParams<
  Path extends ApiPath,
  Method extends HttpMethod,
> = Get<paths, `${Path}.${Method}.parameters.query`>;

参考:openapi-typescriptと型パズルで作るREST APIクライアント

ここまで実装するだけでも、準備ゼロで移行を始めた頃と比べて圧倒的にスピード感が変わりました。
feature側では主にapi_spec.yaml上で schema として管理されているものを GetContent<> などで型を取り出してエクスポートするだけで、整理整頓がしやすくなり、バックエンドと共通した型をすぐに使えます。
またfeatures/Afeatures/Bの型が使いたい場合など、(ある程度運用のルールは必要かなと思いますが)生成された ./my-openapi-3-schema.ts から型を直接取り出して使用することを許容すればfeature間のimportの解決も比較的しやすく、それでも解決しない場合はsharedに配置する、もう少しドメインを薄くするなどの意思決定もしやすくなりそうです。型管理が非常に効率化しました。

そして何より、Claude CodeをはじめとしたAIエージェントがリポジトリを跨いでAPIの仕様を確認しに行くことがなくなりました。
手元に信頼できる型の情報源があることで、コンテキスト消費量が減っただけでなく、AIエージェントが生成するコードの精度も上がったように思えます🤔

また実装面でも『バックエンドから受け取るデータの型』や、『バックエンドに送る際、最終的にそうあって欲しい型』が齟齬なく明確なのも、それ以外の部分の実装に集中できるという面で恩恵を感じました。

準備②:TanStack Query × key-factory

tanstack.com

APIのデータフェッチャーを用意する作業は、かねてよりAIエージェントにお願いしたいと思っていた筆頭でした。
新しいAPIが生えるたびに書くのはもちろん、今回の移行作業で用意すべきフェッチャー関数の量は200本をゆうに超えており、なかなか億劫な作業のひとつです……😇

毎回似たようなコードを書くだけでなく、「このエンドポイントはfeatures/Aに置くべきか?」といったドメイン整理の判断も都度発生します。
こういった定常作業的で分類・整理を伴うタスクは、AIエージェントが得意とするところです。整理してもらったものをfeatureごとに一元管理可能にしつつ、私たち人間も直感的に関連が理解できるものがよいと考えていました。

そこで採用したのが TanStack Query × key-factory パターンです。

このパターンを選んだ理由は2つあります。

1つ目は型安全性の高さにあります。クエリキーをfactoryで一元管理することで、サジェストが効くようになりキーの指定ミスが減りました。

2つ目は可読性の高さです。クエリキーの定義を見れば「このアプリがどのAPIをどういう構造で呼んでいるか」が一目でわかりやすいと言った利点があります。

queryKeys.ts というファイルが各featuresに必ずあるので、そのフィーチャーの全クエリキーが把握できます。
また、keyの命名にルールを適用できるので命名から意図が読み取りやすくパターン化できるので、AIエージェントにこの辺の設計をまるっと投げてしまっても一度ボイラープレートのようなものを作ってしまえばほぼ自動化できるのも魅力的でした。

// queryKeys.ts
myDataKeys.list(params)      // 一覧
myDataKeys.detail(id)        // 詳細
myDataKeys._def              // フィーチャー全体

// ❌ これだと何のキーか文脈依存でわかりづらい
queryKey: ['myData', 'list', params]
 
// ✅ これなら構造が一目瞭然になる
...myDataKeys.list(params)
// usePostMyData.ts
export const usePostMyData = () => {
  const queryClient = useQueryClient();
  const mutation = useMutation({
    mutationFn: (data: Data) => apiClient.post('/my-data', data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: myDataKeys._def }); // 一覧・詳細など関連するGETクエリのキャッシュをまとめて無効化
    },
  });

  return {
    postMyData: mutation.mutateAsync,
  };
};

mutationの onSuccess に全クエリキーのルートのキャッシュを無効化するようにしています。
そうすることでデータが自動的に再取得され、コンポーネント側で命令的に refetch() をすることなく、「mutationが成功したら一覧を更新する」という意図を、コンポーネントから切り離して宣言的に表現できるのが気持ちいいポイントです😎

この構造、Claude Codeにも刺さった

ここまで「スキーマ駆動開発」と「key-factoryパターン」を準備として紹介してきましたが、どちらも人間が読みやすい構造にするという意図で設計しています。

そして「人間が読みやすい」は、ほぼそのまま「AIエージェントが推論しやすい」に翻訳できます。

openapi-tsで生成したスキーマはバックエンドの仕様がそのまま型として落ちているので、AIエージェントが「このエンドポイントは何を受け取り、何を返すか」を自力で解釈できます。またkey-factoryのクエリキー定義はAPIの呼び出し構造が宣言的に並んでいるので、「どのfeatureがどのAPIに依存しているか」を文脈として与えやすくなります。

また「バックエンドのリポジトリをわざわざ見に行かなくても、schema.ts からAPIフェッチャーを作れる」 という点もあります。
型の情報源が手元に揃っていれば、AIエージェントはコンテキストをリポジトリをまたいで集めに行く必要がなく、その分だけ精度の高いコードを作成してくれるようになった実感があります。
Claude Codeに作業を依頼したとき、この2つの準備がある状態とない状態では、生成コードの精度が体感でかなり変わりました。型の取り違えやimportのミスが減り、featureのディレクトリ構造にも沿ったコードが出てくるようになりました。
「AIに頼みやすいコード」を先に設計しておく、というアプローチは、移行作業に限らず今後のチーム開発でも活きてきそうだと感じています。

おまけ:ここまでで結構完成度の高いページができた

CRMはテーブルUIを使った一覧ページがとても多く、ページネーション、フィルター、ソートといった機能を持つ似たような構造のページがいくつか存在します。
これらは新規UIを作るというよりも、一度どこかのページを実装すれば同じパターンで量産できるページです。つまり「AIエージェントにUIの実装からまるっとおまかせしたい」作業の一つでした。

そこで一覧ページ作成用SKILL(create-list-page)を用意し、以下を盛り込んでいます。

  • ページのボイラープレートテンプレート: TanStack RouterのsearchParamsをZodでバリデーションする構造、Route.useSearch() / Route.useNavigate() の使い方、ページング・フィルター変更時のスクロールリセットまで含んだ実装例
  • チェックリスト: satisfies Required<GetXxxParams> での型チェック、stripSearchParams でURLをシンプルに保つ設定など、抜け漏れやすいポイントを列挙
  • 参考実装ファイルのパス: src/routes/_pathlessLayout/template/index.tsx を参照するよう明記

これを用意した上で、たとえばこんな一言を投げます。

型とフェッチャーはReactリポジトリの features/template/ にあるものを使用し、
Vueリポジトリの pages/template/index.vue を参考に
ページネーションとテーブルフィルターを備えたテンプレート一覧ページのUIを作成する。
ただし、ビジネスロジックは作成しなくて良い。

こんなふわっとしたプロンプトでもClaude Codeは、既存のVueページの構造を読み取りつつ、Reactの型・フェッチャーを使った一覧ページをSKILLのボイラープレートに沿って生成してくれました😳

スキーマ駆動で型が整い、key-factoryでAPIの呼び出し構造が明確になっていることで、Claude Codeは「何を取得して・どう表示するか」を自力で組み立てられる状態になっています。

ここまでの準備がここで一気に回収される感覚です🤩🎰

移行はまだ続く

今回ご紹介したのは、あくまで移行プロジェクトの「仕込み」の話です。

200本超のフェッチャーをどう生成・管理していくか、CLAUDE.mdやSKILLSを使ったClaude Codeのワークフロー最適化など、まだまだ効率的に移行作業を行うための手段を模索している中ではありますが、準備に時間をかけて良かった、という実感は毎日積み上がっています😌

一緒に働く仲間を募集しています✨

TalentXでは現在新しい仲間を募集中です! talentx.brandmedia.i-myrefer.jp

カジュアル面談も実施しておりますので、ぜひお気軽にご応募ください!
i-myrefer.jp