ReactでTanStack Routerを使い、型の付いたルーティングを書く方法

​​React​TanStack Router,TypeScript

TanStack Router では、ルートの定義をもとに、Link に書けるパス、パスパラメーター、search パラメーターの型が作られます。ルーターを Register に登録しておけば、存在しないパスへのリンクや、パラメーターの渡し忘れが tsc の型チェックで止まります。

検証環境でトップ、猫の一覧、猫の詳細の3画面を作り、わざと間違えたリンクを6通り書いて、どんなエラーになるかを記録しました。あわせて、ブラウザでリンクをたどったときの URL と、URL を手で書き換えたときの動きを確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4 です。使ったのは @tanstack/react-router 1.170.46(中で使われる @tanstack/router-core は 1.171.39)、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Chrome for Testing 153(Playwright 1.56.1 で操作)で動かしました。StrictMode では包んでいません。

公式ドキュメントがすすめるファイルベースのルーティング(ファイルの置き方からルートの定義をプラグインが自動で作る方式)、Vite との組み合わせ、loader でのデータ取得、Zod などの検証ライブラリとの組み合わせ、コード分割の5つは、この記事では試していません。ここではルートをコードで書く方式だけを扱います。

ルートを書いて、ルーターを Register に登録する

ルートをコードで書く今回の形では、ルーター用に入れたパッケージは @tanstack/react-router だけで、プラグインなどを足さずに動きました。ルートは createRootRoute で一番上のルートを作り、その下に createRoute でつないでいきます。子のルートには getParentRoute で親を教えます。型安全についてのガイドには、これを書かないと親のルートの型が子に伝わらないと書かれています。

検証に使ったルートは、トップ(/)、猫の一覧(/cats)、猫の詳細(/cats/$catId)の3つです。パスの中の $catId がパスパラメーターになります。

tsx
const rootRoute = createRootRoute({
  component: () => (
    <>
      <nav>
        <Link to="/">トップ</Link>{' '}
        <Link to="/cats" search={{ sort: 'name' }}>猫の一覧</Link>
      </nav>
      <Outlet />
    </>
  ),
})

const indexRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: () => <h1>トップ</h1>,
})

type CatsSearch = { sort: 'name' | 'age' }

const catsRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: 'cats',
  validateSearch: (search: Record<string, unknown>): CatsSearch => ({
    sort: search.sort === 'age' ? 'age' : 'name',
  }),
  component: CatsPage,
})

const catRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: 'cats/$catId',
  params: {
    parse: ({ catId }) => ({ catId: Number(catId) }),
    stringify: ({ catId }) => ({ catId: String(catId) }),
  },
  component: CatPage,
})

const routeTree = rootRoute.addChildren([indexRoute, catsRoute, catRoute])

export const router = createRouter({ routeTree })

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

最後の declare module が、ルーターの登録です。Link や useNavigate は、どのルーターと一緒に使われるかを自分では知りません。TypeScript の宣言のマージで Register に router の型を入れると、ライブラリから読み込んだ Link などが、このルーターの型をもとに型チェックされるようになります。

あとは RouterProvider に router を渡して描画するだけです。

tsx
createRoot(document.getElementById('root')!).render(
  <RouterProvider router={router} />,
)

CatsPage と CatPage の中身は、あとの節で見ていきます。

わざと間違えたリンクは、6つとも tsc で止まった

上のルートと同じプロジェクトに、ルーターを import しない別のファイルを足して、間違えたリンクを6つ書き、tsc にかけました。Link と useNavigate は、ライブラリから読み込んだだけです。

tsx
export function Mistakes() {
  const navigate = useNavigate()
  return (
    <>
      {/* 1. パスの書き間違い(cats を cat と書いた) */}
      <Link to="/cat">一覧</Link>
      {/* 2. パスパラメーターを渡し忘れた */}
      <Link to="/cats/$catId">たま</Link>
      {/* 3. catId に文字列を渡した(parse で number にしている) */}
      <Link to="/cats/$catId" params={{ catId: '1' }}>たま</Link>
      {/* 4. sort に決めていない値を渡した */}
      <Link to="/cats" search={{ sort: 'weight' }}>体重順</Link>
      {/* 5. search を渡し忘れた */}
      <Link to="/cats">一覧</Link>
      {/* 6. navigate でパラメーターの名前を間違えた */}
      <button onClick={() => navigate({ to: '/cats/$catId', params: { id: 1 } })}>
        移動
      </button>
    </>
  )
}

6つとも、それぞれの行でエラーになりました。型の名前が長い3つのエラーは、途中を「…」で省いています。

text
$ npx tsc -p tsconfig.mistakes.json --pretty false; echo "exit=$?"
mistakes/mistakes.tsx(8,13): error TS2820: Type '"/cat"' is not assignable to type '"." | ".." | "/" | "/cats" | "/cats/$catId"'. Did you mean '"/cats"'?
mistakes/mistakes.tsx(10,8): error TS2741: Property 'params' is missing in type '{ to: "/cats/$catId"; children: string; }' but required in type 'MakeRequiredPathParams<…'.
mistakes/mistakes.tsx(12,41): error TS2322: Type 'string' is not assignable to type 'number'.
mistakes/mistakes.tsx(14,34): error TS2322: Type '"weight"' is not assignable to type '"age" | "name"'.
mistakes/mistakes.tsx(16,8): error TS2741: Property 'search' is missing in type '{ to: "/cats"; children: string; }' but required in type 'MakeRequiredSearchParams<…'.
mistakes/mistakes.tsx(18,71): error TS2353: Object literal may only specify known properties, and 'id' does not exist in type 'ParamsReducerFn<…'.
exit=1

1つ目のエラーを見ると、to に書けるのは "/"、"/cats"、"/cats/$catId" と、"."、".." だけになっています。定義した3つのルートのパスが、そのまま並んでいます。"/cat" に対しては「"/cats" のことでは?」と候補まで出ました。

2つ目と5つ目は、params や search を書き忘れたものです。パスパラメーターのあるルートや、search パラメーターが必ず要るルートへのリンクでは、それらを省くと型エラーになります。

Register を書かないと、間違ったパスも通る

declare module を書かずに、同じように "/cat" へのリンクを書いて試しました。ルートは /cats しかありません。

tsx
const rootRoute = createRootRoute()
const catsRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: 'cats',
})
const routeTree = rootRoute.addChildren([catsRoute])
export const router = createRouter({ routeTree })

export function Nav() {
  // パスを書き間違えても、型チェックを通ってしまう
  return <Link to="/cat">一覧</Link>
}
text
$ npx tsc -p tsconfig.noreg.json --pretty false; echo "exit=$?"
exit=0

エラーは1つも出ませんでした。ルーターを作っただけでは Link の型は絞られないので、Register の宣言は忘れずに書く必要があります。

パスパラメーターは params.parse で数値にできる

URL から取り出したパスパラメーターは、そのままでは文字列です。パスパラメーターのガイドの例でも、/blog/123 を開いたときの params は { postId: '123' } になっています。

検証では、ルートの params に parse と stringify を書き、catId を数値として扱えるようにしました。ルートのオプションの API リファレンスによると、parse はルートが URL に当てはまったときに呼ばれ、戻り値の型がルーターのほかの部分にも伝わります。stringify は、移動先の URL を組み立てるときに呼ばれます。詳細のコンポーネントでは、ルートの useParams で値を受け取ります。

tsx
function CatPage() {
  const { catId } = catRoute.useParams() // catId の型は number
  const cat = cats.find((c) => c.id === catId)
  if (!cat) return <h1>見つかりません</h1>
  return (
    <>
      <h1>{cat.name}</h1>
      <p>{cat.age}歳(catId: {catId}、型: {typeof catId})</p>
    </>
  )
}

リンクを作る側も数値を渡します。一覧では params={{ catId: cat.id }} と書いており、わざと間違えたリンクの3つ目のように、文字列の '1' を渡すと型エラーになりました。

ブラウザで /cats/3 を開くと、画面には「catId: 3、型: number」と出ました。一方で /cats/abc のように数字でない値を直接開くと、Number('abc') は NaN になるため、猫のデータ(cats)の中に見つからず「見つかりません」と表示されました。型が number でも、中身が正しい数値とは限りません。API リファレンスには、parse で false を返すと、そのルートを飛ばして別のルートを探す書き方も載っています(試験的な機能とされています)。

search パラメーターの型は validateSearch で決まる

?sort=age のような search パラメーターの型は、ルートの validateSearch が返す値の型になります。search パラメーターのガイドでは、validateSearch が受け取るのは検証前の値(Record<string, unknown>)で、おかしな値には、代わりに使う妥当な値を返すのがよいとされています。検証では、sort が 'age' のときだけ 'age'、それ以外はすべて 'name' にしました。

一覧のコンポーネントでは、ルートの useSearch で値を受け取ります。sort の型は 'name' | 'age' になります。

tsx
function CatsPage() {
  const { sort } = catsRoute.useSearch() // sort の型は 'name' | 'age'
  const list = [...cats].sort((a, b) =>
    sort === 'age' ? a.age - b.age : a.name.localeCompare(b.name, 'ja'),
  )
  return (
    <>
      <h1>猫の一覧({sort === 'age' ? '年齢順' : '名前順'})</h1>
      <Link to="/cats" search={{ sort: 'name' }}>名前順</Link>{' '}
      <Link to="/cats" search={{ sort: 'age' }}>年齢順</Link>
      <ul>
        {list.map((cat) => (
          <li key={cat.id}>
            <Link to="/cats/$catId" params={{ catId: cat.id }}>
              {cat.name}
            </Link>
          </li>
        ))}
      </ul>
    </>
  )
}

この書き方だと、CatsSearch の sort に ? が付いていないので、/cats へのリンクには毎回 search が要ります。わざと間違えたリンクの5つ目のエラーがそれです。リンクのたびに書くのが面倒なら、型を { sort?: 'name' | 'age' } にし、validateSearch も 'age' 以外は undefined を返すようにします。この形では、名前順を sort を付けない状態で表します。

tsx
type CatsSearch = { sort?: 'name' | 'age' }

const catsRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: 'cats',
  validateSearch: (search: Record<string, unknown>): CatsSearch => ({
    sort: search.sort === 'age' ? 'age' : undefined,
  }),
})

別のファイルでこの形に変えて試すと、search のない <Link to="/cats"> は通り、sort: 'weight' だけがエラーになりました。

text
$ npx tsc -p tsconfig.optional.json --pretty false; echo "exit=$?"
optional/router.tsx(27,34): error TS2322: Type '"weight"' is not assignable to type '"age" | "name" | undefined'.
exit=1

ブラウザでリンクをたどると、URL はこう変わる

本番用にビルドしたアプリを Chrome for Testing で開き、「猫の一覧」「年齢順」「ミケ」の順にリンクをクリックしました。下の枠の URL は、撮影用のスクリプトが Playwright でページの URL を読んで表示したものです。

トップから猫の一覧、年齢順、ミケの詳細へと3回リンクをクリックし、URL が /cats?sort=name、/cats?sort=age、/cats/3 と変わる様子
リンクを3回クリックしたときの画面と URL の変わり方

search={{ sort: 'name' }} を付けたリンクの href は /cats?sort=name になりました。ミケのリンク(params={{ catId: cat.id }} で、catId は 3)をたどると /cats/3 になりました。

URL を手で書き換えたときの動きも見ました。/cats?sort=weight を直接開くと、画面は名前順になり、URL も /cats?sort=name に書き換わりました。search パラメーターのない /cats を開いたときも同じく /cats?sort=name になりました。validateSearch で直した値が、画面だけでなく URL にも反映されています。

text
/cats?sort=weight を直接開く
  URL: /cats?sort=name
  h1: 猫の一覧(名前順)
  li: クロ, たま, ミケ
/cats を search なしで直接開く
  URL: /cats?sort=name
  h1: 猫の一覧(名前順)
  li: クロ, たま, ミケ

型が守ってくれるのは、コードの中で書くリンクまでです。アドレスバーに打ち込まれる URL は、型チェックの対象になりません。そのため、validateSearch や params.parse で、おかしな値が来たときの扱いを決めておくことになります。

​Posted by 86