LoginSignup
6
1

TanStack Router v1を使ってみたけど、型推論効きまくって型安全に書けるしコード分割とかも組み込まれててすげー便利そうだぞ

Posted at

はじめに

React向けのRouterライブラリである「TanStack Router」を触ってみました。Reactを触っているとよく出てくる非同期状態管理などのライブラリであるTanStack Queryの兄弟みたいなやつで、Router機能を提供します。TanStack Queryはその名前がReact Queryだった時代からお世話になっているのですが、実は兄弟がどんどん増えていて、最近知ったので改めて触ってみようという記事になります。

作ったもの

Routerライブラリって?

Reactを始めとするシングルページアプリケーションのライブラリは、その名の通り実体のHTMLは単一で、JavaScriptによって描画内容を構築しています。詳細な歴史や経緯は省きますが、そうすることによって解決できる課題がありJavaScriptでUIを構築するためのライブラリたちは進化してきました。ただ、一枚のHTMLで表現できる幅には限界もあり、シングルページと言いつつページ遷移(をしているように振る舞わせる1こと)によってリッチにアプリを構築するようになりました。むき出しのブラウザのAPIを利用するのではなく、より便利により開発体験のよい形でページ遷移を実現しようということでRouterライブラリは進化を続けています。

Routerライブラリって別にあるよね

あります。私が開発で利用したことあるライブラリだとReact Routerがあります。 Remixによって開発されたライブラリで、例えば、以下のようにRoutingを定義します。

const router = createBrowserRouter([
  {
    path: "/",
    element: (
      <div>
        <h1>Hello World</h1>
        <Link to="about">About Us</Link>
      </div>
    ),
  },
  {
    path: "about",
    element: <div>About</div>,
  },
]);

この場合、ページRootならHello Worldという文字とAbout Usというリンクが表示されます。そして、このリンクを押下することで表示上のURLは/aboutになり、Aboutという文字列が表示されます。

Remixが開発していてRemixを触っていると出てくるloaderやactionを模した機能も含まれており、かなりRemixに近い開発体験を得ることができます。

ReactのRouterライブラリだと、著者の見解だとReact Routerがおそらくデファクトスタンダートでnpm trendsを見ても、tanstack-routerは見る影もありません。

ではなぜTanStack Routerを取り上げるのか

その理由はTanStack Routerの型安全・開発者体験(DX)への強い関心に惹かれたからです。

TanStack RouterはDXへの強い関心を持っており、HPにはDXについて書かれたページが存在します。

例えば、TanStackRouterは以下のように問われます。

Why do I have to do things this way?
Why is it done this way? and not that way?
I'm used to doing it this way, why should I change?

なんで今あるやり方に慣れているのに変えなきゃいけないの?

TanStack Routerはこれに以下のように答えます。

It's important to remember that TanStack Router's origins stem from Nozzle.io's need for a client-side routing solution that offered a first-in-class URL Search Parameters experience without compromising on the type-safety that was required to power its complex dashboards.
And so, from TanStack Router's very inception, every facet of its design was meticulously thought out to ensure that its type-safety and developer experience were second to none.

複雑なアプリを「型安全を損なわず」「開発者体験をよく」作れるようにするところから始まったぜ!

そして、その実現のために他のRouterライブラリとは異なるアーキテクチャを持っています。

  1. Route configuration boilerplate?:You have to define your routes in a way that allows TypeScript to infer the types of your routes as much as possible.
  2. TypeScript module declaration for the router?:You have to pass the Router instance to the rest of your application using TypeScript's module declaration.
  3. Why push for file-based routing over code-based?:We push for file-based routing as the preferred way to define your routes.
  1. Route定義のためのボイラーテンプレート
  2. TypeScriptのモジュール宣言
  3. ファイルベースのルーティング

これがキーワードです。それぞれどのような理由で採用されたのでしょうか。

ルーティングの定義にボイラーテンプレートを使う理由

React RouterなどではJSXでルーティングを定義できます。例えば以下のように。

function App() {
  return (
    <Routes>
      <Route path="/" element={<Layout />}>
        <Route index element={<Home />} />
        <Route path="about" element={<About />} />
        <Route path="dashboard" element={<Dashboard />} />
        <Route path="*" element={<NoMatch />} />
      </Route>
    </Routes>
  );
}

この例では、URLが/にマッチするとき、LayoutというComponentが描画され、さらに/以降がどのようにマッチするかで描画するComponentが決まります。例えば/aboutならAboutというComponentが描画されます。

しかし、JSXに表現すると、型定義を推論することができません。この例だと、Appの戻りはJSX.Elementです。型推論が効かないということはLinkを/avoutとタイポしても静的解析で事前に検知することができません。

第二の選択肢として、Routeの階層全体をObjectとして表現するという手があります(冒頭のReact Routerのコードがその例です)。これであれば、ルーティングの構造が明確になります。
しかし、この仕組みの場合、大規模なアプリケーションでは不都合が生じてくるとTanStack Routerは言います。

It's not very scalable: As your application grows, the tree will grow and become harder to manage. And since it's all defined in one file, it can become very hard to maintain.
It's not great for code-splitting: You'd have to manually code-split each component and then pass it into the component property of the route, further complicating the route configuration with an ever-growing route configuration file.

大規模なアプリだとルーティングの保守性が低くなってしまう点、コード分割が組み込まれていないよという点を指摘しています。

TanStack Routerは型推論が効く&スケーラブルでコード分割もしやすいアーキテクチャを提示しているということです。

具体的には

What we found to be the best way to define your routes is to abstract the definition of the route configuration outside of the route-tree. Then stitch together your route configurations into a single cohesive route-tree that is then passed into the createRouter function.

Route定義を抽象化する(僕たちが意識しないでいいようにする)ということです。
Routerのインスタンスを宣言するまでが私達の仕事で、あとは裏でライブラリがよしなにRoute定義を作ってくれるようにするということです。

TypeScriptのモジュール宣言を使う理由

型推論を効かせるために、個別のRouteインスタンスをインポートするとバンドルサイズの増加などの悪影響をもたらす可能性があります。そのため、アプリケーション内の一箇所でモジュールを定義するというアプローチを取っています。

具体的には、

import { router } from '@/src/app'
export const PostsIdLink = () => {
  return (
    <Link<typeof router> to="/posts/$postId" params={{ postId: '123' }}>
      Go to post 123
    </Link>
  )
}

とするのではなく、

// src/app.tsx
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

としています。
プロジェクト全体ですべてのRouterの型定義を参照できるようにしています。

なぜファイルベースのルーティングをするのか

コードベースのルーティング定義では親子関係の定義を正しくコーディングする必要があり、Routeが増えるとそのコードベースが膨大になることを指摘しています。
例えば、以下のような定義が必要になるとしています。

import { createRoute, lazyRouteComponent } from '@tanstack/react-router'
import { postsRoute } from './postsRoute'

export const postsIndexRoute = createRoute({
  getParentRoute: () => postsRoute, // 親ルーター
  path: '/', // 自身のパス
  component: lazyRouteComponent(() => import('../page-components/posts/index')), // コード分割の設定
})

しかし、ファイルベースのルーティングを採用すれば、親子関係はファイルの構成によって判断されるので、コードベースを減らすことができます。

// src/routes/posts/index.lazy.ts => ファイル配置から自明にpostsが親であることがわかる
import { createLazyFileRoute } from '@tanstack/react-router'

export const Route = createLazyFileRoute('/posts/')({
  component: () => 'Posts index component goes here!!!',
})

そして、TanStack RouterではCLI(やViteのプラグイン)によってこれらの機能の利便性を高めています。

まとめると、①型推論が効くようにインスタンスを定義する(各インスタンスを組み合わせたRoute定義を作るという処理は抽象化する)、②型推論のためにいろいろなところでインポートしないで済むようにdeclare moduleを使う、③保守性を高めるためにファイルベースのルーティングを推す(CodeBasedなRoutingもできはする)、ということです。

以上のようなアーキテクチャの独自性に魅力を感じました。

実際に触ってみる

ここから実際に触ってその内容を確認してみようと思います。

ファイルベースのルーティングを実現するためのお決まりごと

TanStack Routerでは先述の通りファイルの配置でRouteを表現するのでどこにファイルを置くかが重要です。
TanStack Routerではflatにファイル名でRouteを表現する方法と、ディレクトリを用いる方法があり、両方同時に使うこともできます。

例えば、flatなRoutingを利用する場合、ファイル名は以下のRoutingに対応します。

ファイル名 Route
index.tsx /
about.tsx /about
posts.tsx /posts
posts.index.tsx /posts (exact)
posts.$id.tsx /posts/$id

posts.tsxposts.index.tsxの違いは、exactにRouteマッチをするかです。
/postsというURL場合は、posts.tsxposts.index.tsxがマッチし、/posts/1というURLの場合、posts.tsxposts.$id.tsxがマッチします。
例えば、/posts配下で投稿の一覧を表示させるページを構築し、何も選択していない場合(/posts)は「Select a post!」という文字列を表示する、なにかしか投稿を選択した場合はURLを/posts/1など特定の投稿のIDのURLに変更し、選択した投稿の詳細を表示するといったUIを実現することができます。

また、__root.tsxは特別なファイルで、すべてのRouteにマッチングします。全ページで共有するNavBar部の実装などに使えます。
そして、全Routeで共有するContextなども定義することができます。例えば、認証情報を型定義してcontextとして使い回すというユースケースが考えられます。

import {
  createRootRouteWithContext,
  createRouter,
} from '@tanstack/react-router'

interface MyRouterContext {
  user: User
}

const rootRoute = createRootRouteWithContext<MyRouterContext>()({
  component: App,
})

先ほど、/postの例で示したように、複数のRouteがマッチするという構成にすることができ、親Routeから子Routeの表示位置などを指定することができます。この場合は<Outlet/>を利用します。

function Posts() {
  const posts = xxxx();

  return (
    <>
      {posts.map((post) => {
        //
      })}

      <Outlet />
    </>
  );
}

例えば、このように書けば、URLが/postsの場合はpostの配列をmapした要素の下にposts.index.tsxの内容を表示します。また、URLが/posts/1の場合はpostの配列をmapした要素の下にposts.$id.tsxの内容が表示されます2

各Routeの構成

各Routeは以下のようなインスタンスを持ちます。

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

export const Route = createFileRoute("/posts")({
  component: PostsComponent,
  loader: () => fetchPosts(),
});

componentには/postsで表示したいComponentを定義できます。loaderはRouteマッチしたときに読み込む処理などを書きます。
他にもbeforeLoadを使えば、loaderよりも前処理を実施できます(例えば未ログインのチェックや権限制御などが考えられます)。

loaderでreturnした値はcomponent側でもRoute.useLoaderData()を使うことで利用することができます。このあたりはRouteがインスタンスなので型推論が効くため利用しやすいです。

__root.tsxで定義したcontext、各URLのpathパラメータ、queryパラメータなどはloaderなどの引数として利用することができます。

export const Route = createFileRoute("/todos/$id")({
  component: TodoItem,
  loader: ({ context, params }) => fetchTodoItem(context.user, params.id),
});

また、Component内からも参照することができます。

export const Route = createFileRoute('/posts/$postId')({
  component: PostComponent,
})

function PostComponent() {
  const { postId } = Route.useParams()
  return <div>Post {postId}</div>
}

このあたりガッチリ型推論が効くため、利便性が高いです。

コード分割

TanStack Routerの面白いところはコード分割をライブラリの機能として組み込んでいるところです。
Routeを定義するファイル名に.lazyというsuffixをつけることでそのRouteをコード分割できます。
例えば、posts.lazy.tsxというRouteを用意して、

import { createLazyFileRoute } from '@tanstack/react-router'

export const Route = createLazyFileRoute('/posts')({
  component: Posts,
})

function Posts () {
  ...
}

と定義しておけば、ビルド時に勝手に分割しておいてくれます。コード分割とか考え始めると複雑になりすぎるよと言っていた点はこのように解消されています。コードを分割して、importして~という処理を抽象化してくれていて、こちらは「このページはlazy loadでいいな、ファイル名を変えておこう」でOKだということです。

型推論が効くという話

TanStack Routerの型推論がよく効くというのはどのように実現されているのでしょうか。TanStack RouterはViteのPluginやCLIツールが整備されており、これらを利用することで全Routeを統合したRouteTreeを定義するファイル(src/routeTree.gen.ts)を自動生成してくれます。
例えば、以下のようなファイルが出力されます。

出力ファイル
/* prettier-ignore-start */

/* eslint-disable */

// @ts-nocheck

// noinspection JSUnusedGlobalSymbols

// This file is auto-generated by TanStack Router

import { createFileRoute } from '@tanstack/react-router'

// Import Routes

import { Route as rootRoute } from './routes/__root'
import { Route as PostsIdImport } from './routes/posts.$id'
import { Route as PostsIndexImport } from './routes/posts.index'

// Create Virtual Routes

const PostsLazyImport = createFileRoute('/posts')()
const IndexLazyImport = createFileRoute('/')()

// Create/Update Routes

const PostsLazyRoute = PostsLazyImport.update({
  path: '/posts',
  getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/posts.lazy').then((d) => d.Route))

const IndexLazyRoute = IndexLazyImport.update({
  path: '/',
  getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))

const PostsIndexRoute = PostsIndexImport.update({
  path: '/',
  getParentRoute: () => PostsLazyRoute,
} as any)

const PostsIdRoute = PostsIdImport.update({
  path: '/$id',
  getParentRoute: () => PostsLazyRoute,
} as any)

// Populate the FileRoutesByPath interface

declare module '@tanstack/react-router' {
  interface FileRoutesByPath {
    '/': {
      preLoaderRoute: typeof IndexLazyImport
      parentRoute: typeof rootRoute
    }
    '/posts': {
      preLoaderRoute: typeof PostsLazyImport
      parentRoute: typeof rootRoute
    }
    '/posts/$id': {
      preLoaderRoute: typeof PostsIdImport
      parentRoute: typeof PostsLazyImport
    }
    '/posts/': {
      preLoaderRoute: typeof PostsIndexImport
      parentRoute: typeof PostsLazyImport
    }
  }
}

// Create and export the route tree

export const routeTree = rootRoute.addChildren([
  IndexLazyRoute,
  PostsLazyRoute.addChildren([PostsIdRoute, PostsIndexRoute]),
])

/* prettier-ignore-end */

自分でRoute定義のObjectを何かしらの形で管理するのではなく、Route定義のオブジェクトは自動生成してメンテコストをなくそうというのはこのような意味でした。

そして、このObjectはすべてのRouteの情報が詰まっているのでとても型推論的には嬉しいです。ただし、このインスタンスを各所でimportすると煩雑だしバンドルサイズが大きくなってしまいます。そこで、以下のように、TypeScriptのdeclare moduleを利用します。

import React, { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'

// Import the generated route tree
import { routeTree } from './routeTree.gen'

// Create a new router instance
const router = createRouter({ routeTree })

// Register the router instance for type safety
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

// Render the app
const rootElement = document.getElementById('app')!
if (!rootElement.innerHTML) {
  const root = ReactDOM.createRoot(rootElement)
  root.render(
    <StrictMode>
      <RouterProvider router={router} />
    </StrictMode>,
  )
}

このようにmoduleにしてしまうことで各ファイルでの型推論がうまく働く(けど、各ファイル側にインスタンスをimportしなくて済む)という仕組みを実現しています。

実際にコードの書きっぷりを見ていくことで、TanStack Routerの設計思想をどのように実現しているかが理解できてきましたね。

最後に

今回は、TanStack Routerというライブラリを触ってみてその設計思想やコード例を紹介しました。「型安全を重視して開発者体験を損なわせず複雑なアプリを構築しやすくする」という強い思想が根底にあり、他のRouterライブラリとは異なるアーキテクチャを持つ面白いライブラリだと感じました。v1ながら、機能的にはとても充実しており、今回紹介しきれていない機能も様々あります。また、公式のexampleも充実しているので興味を持たれた方は是非触ってみてください。「ここにも型推論が効くんだ!」という感動は実際に触ってみるのが一番実感できると思います。

  1. 例えば、History APIを利用してブラウザの履歴を操作して画面遷移したかのように見せています。実際にはサーバへのリクエストをしなかったとしても。

  2. 実際にブラウザ上でみたときの上下を意味していません。Stylingで何とでもなりますので。HTMLをテキストとして上から読んだときの上下を意味しています。

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1