バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

フロントエンド新規開発のルーティングライブラリにReact RouterではなくTanStack Routerを採用した話

フロントエンド新規開発の ルーティングライブラリに React Routerではなく TanStack Routerを採用した話

はじめに

こちらは バイセルテクノロジーズ Advent Calendar 2024 の13日目の記事です。

昨日は渡邊さんによるスクラム経験者がエンジニア採用チームを立ち上げた話でした。

こんにちは、開発1部所属フロントエンドエンジニアの望月です。

バイセルでは、お客様のご自宅への出張訪問や店舗などで買取業務を行っており、1つ1つの買取業務を案件と呼んでいます。

現在、私の所属するチームでは、この買取案件を総合的に管理する社内向けWebアプリケーション「Deal」を新規開発しています。

DealのフロントエンドにはReactを採用していますが、ルーティングライブラリとしては主流のReact Routerではなく、TanStack Routerを選びました。

この記事では、この選定に至る背景とその理由について解説します。

Reactのルーティングライブラリ選びに悩んでいる方の参考になれば幸いです。

ルーティングライブラリ選定の前提条件

Dealは、買取案件の検索や編集をするための社内向けアプリケーションです。

Dealでは、クライアントサイドレンダリングのみをサポートする純粋なSPAを採用しました。

これは、SEO対策が不要であり、First Meaningful Paintを極度に追求する必要がないという前提で、サーバーサイドレンダリングをサポートするサーバーの保守運用コストが割に合わないと判断したためです。

この判断の詳細は昨年の当社アドベントカレンダーの記事が参考になります。

Dealの画面要件は以下の通りです。

  • ファーストスコープでは単一画面構成である(エラー画面などは必要)
  • ただし、今後のエンハンスで追加画面が必要になる可能性も高い
  • 約20種類の検索条件や選択中の案件などをURLで共有できる

これを踏まえ、Dealのルーティングライブラリの選定では、以下の観点に重きを置きました。

  • 一般的なルーティングができることはもちろん必須
  • Search Paramが制御可能で、扱いやすいこと

主要なルーティングライブラリの比較

現在、Reactアプリのルーティングとして主流なのは、React RouterやNext.jsです。

これらはルーティングライブラリとしての機能はあるものの、フルスタックフレームワークとしての側面や立ち位置も持ち合わせています。

単一画面構成のDealでこれらを選択するのは、いささかオーバースペックに感じました。

そのため、主流なフルスタックフレームワークだけでなく、機能がルーティングのみに絞られたライブラリも選択肢に含めて検討しました。

結論として、今回はTanStack Routerを採用しました。

Next.js

デファクトスタンダードなフルスタックフレームワークで、サーバーサイドレンダリングやAPIルートを標準でサポートしています。

Search Param管理APIも存在し、Web標準のURLSearchParamsに則ったインタフェースになっています。

ただし、クライアントレンダリングのみの静的アセットとしてビルドするStatic Exportsでは、Next.js単体では動的ルーティングを実現できません。

Dealのファーストスコープでは単一画面構成のため要件は満たせますが、今後動的ルーティングが必要になると、サーバーサイドレンダリングの導入やフレームワークの変更などの対応が必要になる可能性があります。

React Router

Reactアプリで最も採用されているルーティングライブラリです。

2024年11月22日にリリースされたReact Router v7では、Remixと統合され、フルスタックフレームワークの機能も追加されました。

Search Param管理APIもサポートしており、Next.js同様にWeb標準のURLSearchParamsに準拠しています。

Water

パッケージサイズがzipで5KB未満の非常に軽量でシンプルなルーティングライブラリです。

ルーティング機能は最小限で、Search Param管理のAPIはありません。

もしSearchParamsを使う場合は、Web標準のURLSearchParamsを使って取得・更新する必要があります。

これはDealの要件とは一致しません。

TanStack Router

Tanstackが2023年12月にリリースしたルーティングライブラリです。

公式が型安全なルーティングライブラリであることや、First-classで型安全なSearch Param APIを提供していることなどを謳っています。

Search Param管理APIもサポートしており、Web標準のURLSearchParamsではなく独自の実装がされています。

Search Param管理APIの使い勝手が一番良く、開発体験も良かったため採用することにしました。

TanStack Routerの採用理由

React RouterとTanStack Routerの2択で迷いましたが、TanStack Routerの型安全なSearch Param管理APIは、Dealの要件と非常に相性が良く、採用を決めました。

Dealでは約20種類の検索条件をSearch Paramで管理する必要があります。

React RouterなどのWeb標準のURLSearchParamsに則ったAPIでは、厳密な型安全性が担保されないというデメリットがあります。

これはURLSearchParamsの仕様によるものです。

  • set・get関数ともに第一引数(フィールドのキー)の型がstringになっていること
  • get関数で取得したSearch Paramのバリューもstring型になっていること

例えば、React Routerで以下のようなオブジェクトをSearch Paramで管理することを考えます。

type SearchParams = {
  index: number,
}

const searchParam = {
  index: 3
}

Search Paramの登録と取得の一例です。

// set params
const [params, setParams] = useSearchParams();
// "index" は型推論されないため、どのようなフィールドも登録可能
params.set("index", String(3));
setParams(params);

console.log(params.toString());
// index=3
const [params] = useSearchParams();

const index = params.get("index");
console.log(params);
// string型の "3" が出力される

例ではnumber型の例を挙げましたが、enum・boolean・配列型などについても同じことが言えます。

一方で、TanStack Routerはこれらの問題を解消し、型安全にSearch Paramを管理できます。

Tanstack Routerでは、以下のようにRoot定義でSearch Paramsのバリデーションを登録することで、Search Paramを型安全に登録・取得できます。

export const Route = createFileRoute('/')({
  validateSearch: (search: Record<string, any>): Params => {
    return {
      index: Number(search?.index),
    }
  }
})
const navigate = useNavigate();
navigate({
  to: "/",
  // 登録時にParams型に型推論してくれる
  search: ({ index }),
});
// 取得時にParams型に型推論してくれる
const params = useSearch({
  from: "/",
});

console.log(params.index)
// numberの 3 が出力される

他のライブラリでもSearchParamを一元管理することで似たような仕組みを作ることもできますが、デフォルトでSearchParamの型安全性を追求しているのはTanStack Routerのみです。

Dealでは、より多くの変数で複雑なオブジェクトを管理する必要があり、Search Paramを型安全に管理できる機能と親和性が高いためTanStack Routerを採用しました。

TanStack Routerを採用するリスク

TanStack Routerを採用するにあたってリスクも考えられました。

2023年12月にリリースされたばかりで、ドキュメントやサンプルが少ないこと、エコシステムもフレームワーク群と比較すると小さいことから、問題解決に時間を要するリスクがあります。

また、Search Param管理APIがWeb標準のURLSearchParamsに則っていないため、他のライブラリとの兼ね合いで問題が発生する可能性も考えられます。

しかし、以下の理由からこれらのリスクは受容しました。

  • 単一画面構成のためルーティングに依存する処理が少なく、比較的容易にライブラリを乗り換えられること
  • ルーティング関連のAPIをラップすることでライブラリ変更時の影響範囲をより小さくし、ライブラリの変更を容易にできること

TanStack Routerを採用してみて

TanStack Routerを採用することで、Search Paramを型安全に管理できるようになったのは大きなメリットでした。

一方で辛い点もありました。

それは、Storybook上で一部のコンポーネントが期待通りに動かないことです。

Dealで検知したStorybookが期待通りに動かないケースは以下の2点です。

  • コンポーネントがuseNavigateを利用しているとStorybookがエラーになること
  • Storybook上のコンポーネントでSearch Paramを取得できないこと

まず、コンポーネントがuseNavigateを利用しているとStorybookでエラーが発生します。

React Routerなどの場合はStorybook addonを導入することで解決しますが、TanStack Routerにはaddonがまだないため、自分でモックを作らなければなりませんでした。

Storybook addonのサポートについては議論されていますが、公式からの明確な回答はありません。

こちらは参考にあるように、Storybook用のルーティングを準備することで回避しています。

参考

import {createMemoryHistory, RouterProvider, createRootRoute, createRouter, createRoute} from '@tanstack/react-router';

// @ts-ignore
export const withRouter: Preview["decorators"][0] = (Story, context) => {
  const rootRoute = createRootRoute();
  const indexRoute = createRoute({
    getParentRoute: () => rootRoute,
    path: "/"
  })
  const memoryHistory = createMemoryHistory({ initialEntries: ["/"] })
  const routeTree = rootRoute.addChildren([indexRoute])
  const router = createRouter({ routeTree, history: memoryHistory })

  // @ts-ignore
  return <RouterProvider router={router} defaultComponent={() => <Story {...context} />} />
}

次に、Storybook上のコンポーネントでSearch Paramを取得できない問題がありました。

URLSearchParamsに則ったライブラリでは、@storybook/addon-queryparamsを利用することで、Search ParamをStoryごとに登録できます。

しかし、TanStack RouterではこれだけではSearch Paramを取得できません。

例えば、次のようなSearch Paramを取得するコンポーネントとそのStorybookがあります。

Storybook上では登録したSearch Paramが取得できず、undefinedになってしまいます。

import { useSearch } from "@tanstack/react-router";

const Sample = () => {
  const searchParams = useSearch({ from: "/" })
  // Storybook上ではsearchParams.indexがundefinedになってしまう
  return <div>{searchParams.index}</div>
}
import type { Meta, StoryObj } from "@storybook/react";
import { Sample } from "./index";

const meta = {
  title: "Sample",
  component: Sample,
  parameters: {
    query: {
      // Search Paramに?index=3を登録する
      index: 3
    },
  },
} satisfies Meta<typeof TopPage>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

DealではSearchParamのラッパー関数を用意しそれをモックすることで、Storyごとに適切なSearch Paramを設定可能にしました。

// useSearchのラッパー関数
import { useSearch } from "@tanstack/react-router";

const useIndex = () => {
  const search = useSearch({
    from: "/",
  });
  return search.index
}
// Storybook用のモック関数
const useIndex = () => {
  const urlParams = new URLSearchParams(location.search);
  const mockIndex = urlParams.get("index");
  return mockIndex
}
// .storybook/main.ts
import type { StorybookConfig } from "@storybook/react-vite";
import { mergeConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths"

const config: StorybookConfig = {
  // 中略
  viteFinal: async (config) => {
    return mergeConfig(config, {
      plugins: [tsconfigPaths()],
      resolve: {
        alias: {
          // useSearchのラッパー関数useIndexをmockして使うことで、storybook上でSearch Paramを取得できる
          "@/hooks/useIndex": "/.storybook/mocks/useIndex.ts",
        },
      },
    });
  },
};
export default config;

このように、TanStack Routerを使った開発では、相性の悪いライブラリに関して自分で解決策を講じる必要がありました。

まとめ

昨今のルーティングはフレームワークを含め、Next.jsかReact Routerの2択に感じています。

しかし、要件とリスクを考慮した結果、DealではTanStack Routerを選択しました。

エコシステムがまだ小さくつらい点もありますが、Search Paramを型安全に管理できることにより、開発体験の面で恩恵を受けています。

なお、今回の選択はあくまで事例の1つであり、ライブラリに優劣をつけるものではありません。

重要なのはプロダクトやチームの要件に合わせて技術を選定することだと考えています。

最後まで読んでいただき、ありがとうございました。この記事が皆さんの技術選定の一助になれば幸いです。

明日の バイセルテクノロジーズ Advent Calendar 2024 は、中舘さんによる 「要件定義からリリースまでやり遂げたことでプロダクトエンジニアとして成長できた話」です。 お楽しみに!

最後に、バイセルでは新卒エンジニアを随時募集しております。興味のある方はぜひ以下の採用サイトをご覧ください。 herp.careers herp.careers