TanStack Router入門
2024-2025 Shunsuke Watanabe
このチュートリアルについて
このチュートリアルはTanStack Routerの基礎に触れてもらうことを目的としています。👨💻サンプルコード → https://github.com/craftgear/tsr-tutorial
discord → https://discord.gg/3havwjWGsw
bluesky → https://bsky.app/profile/craftgear.bsky.social
📝 blog → https://craftgear.github.io/posts/
プロジェクト初期化
npx create-tsrouter-app
ファイル構成
.├── index.html├── public├── src│ ├── main.tsx│ ├── routeTree.gen.ts│ ├── routes│ │ ├── __root.tsx│ │ └── index.tsx│ └── styles.css├── tsconfig.json└── vite.config.js
main.tsx
アプリケーションのエントリポイント、ルーターの設定もここで行うrouteTree.gen.ts
viteのTanStack Routerプラグインによって自動生成されるファイル、ルートの型情報を提供する__root.tsx
大元のルートになるファイル
TailwindcssとDaisyUIのインストール
npm i -D tailwindcss @tailwindcss/vite daisyui
-
vite.config.ts
にtailwindcssプラグインを追加import { defineConfig } from "vite";import viteReact from "@vitejs/plugin-react";import { TanStackRouterVite } from "@tanstack/router-plugin/vite";import tailwindcss from "@tailwindcss/vite";// https://vitejs.dev/config/export default defineConfig({plugins: [TanStackRouterVite({ autoCodeSplitting: true }),viteReact(),tailwindcss(),],test: {globals: true,environment: "jsdom",},}); -
src/styles.css
にtailwindcssとdaisyUIの設定を追加@import "tailwindcss";@plugin "daisyui" {themes: abyss --default;}
開発用サーバー立ち上げ
- これを忘れるとファイルの自動生成が動かないのでとても重要
npm start
レイアウトの追加
-
__root.tsx
を開き、サイト全体のレイアウトを追加import { createRootRoute, Outlet } from "@tanstack/react-router";export const Route = createRootRoute({component: () => (<div className="h-svh flex flex-col"><div className="grow p-8 grid place-items-center overflow-scroll"><Outlet /></div></div>),});
ヘッダーとフッターの作成
-
src/components/Header.tsx
を作成export const Header = () => {return (<div className="navbar bg-base-100 px-8"><div className="flex-1"><a>ブログトップ</a></div><div className="flex-none"><ul className="flex gap-4"><li><a>記事一覧</a></li><li><a>タグ一覧</a></li><li><a>About</a></li></ul></div></div>);}; -
src/components/Footer.tsx
を作成export const Footer = () => {return (<footer className="footer footer-center bg-base-300 text-base-content p-4"><aside><p>Copyright © {new Date().getFullYear()} - All right reserved by 😊</p></aside></footer>);}; -
__root.tsx
にヘッダーとフッターを追加import { createRootRoute, Outlet } from "@tanstack/react-router";import { Header } from "../components/Header";import { Footer } from "../components/Footer";export const Route = createRootRoute({component: () => (<div className="h-dvh flex flex-col"><Header /><div className="grow px-8 grid place-items-center overflow-scroll"><Outlet /></div><Footer /></div>),});
ルートの作成
-
routes/posts/index.tsx
を作成すると、自動で以下の内容が書き込まれますimport { createFileRoute } from '@tanstack/react-router'export const Route = createFileRoute('/posts/')({component: RouteComponent,})function RouteComponent() {return <div>Hello "/posts/"!</div>} -
<Header />
コンポーネントのaタグを<Link />
で置き換えますimport { Link } from "@tanstack/react-router";export const Header = () => {return (<div className="navbar bg-base-100 px-8"><div className="flex-1"><Link to="/">ブログトップ</Link></div><div className="flex-none"><ul className="flex gap-4"><li><Link to="/posts">記事一覧</Link></li><li><Link to="/tags">タグ一覧</Link></li><li><Link to="/about">About</Link></li></ul></div></div>);}; -
タグ一覧とAboutで型エラーが出ていることを確認します
-
routes/tags.tsx
とroutes/about.tsx
を新規作成し、エラーを解消します
パス区切りはディレクトリと . が使えます
- posts/index.tsx と posts.index.tsx は同じパスを指します。
index.tsx
と route.tsx
の違い
- posts/index.tsx と posts/route.tsx は同じパスを指しますが、
<Outlet />
が動くか動かないかの違いがあります。
ダイナミックルートの作成
-
routes/posts/$postId.tsx
ファイルを新規作成しますimport { createFileRoute } from "@tanstack/react-router";export const Route = createFileRoute("/posts/$postId")({component: RouteComponent,});function RouteComponent() {const { postId } = Route.useParams();return <div>Hello "/posts/$postId"! {postId}</div>;} -
localhost:3000/posts/123 にアクセスし、postIdが表示されることを確認します
キャッチオールルート
$
のみ のルートはキャッチオールルートになり、パス区切りに関係なくそれ以降の全てにマッチしますposts/$
が http://localhost/posts/xxx と http://localhost/post/xxx/yyy のどちらにもマッチします- マッチした部分は
params._splat
プロパティで参照できます
その他のルート
パスレスルート
_
で始まるルートはどのパスにもマッチしない- 子ルートをラップして、ミドルウェアのようにいろいろな機能を提供するのに使われる
- 共通レイアウトの適用
- 共通
loader
の実行 - 共通パラメーターの検証
- 共通エラーコンポーネントの定義
- 共通コンテキストの追加
_layout.tsx
がある場合、_layout.index.tsx
あるいは_layout/index.tsx
が http://localhost/ にマッチし、_layout.tsx
の<Outlet />
に_layout.index.tsx
あるいは_layout/index.tsx
の内容が表示される
ネスト解除ルート
_
で終わるルートはルートコンポーネントの入れ子構造の一段階上になる- 特定のパスだけレイアウトを外したい場合など
- https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#non-nested-routes
無視されるルート
-
で始まるファイルは無視される、ファイルの自動生成も動かない。
APIルート
/routes/api
はAPIルートのための特別なディレクトリとして予約されている- APIルートには外部に公開するAPIを置く
- APIルートの場所を
/routes/api
以外にしたい場合、tsr.config.json
のapiBase
を変更する
バーチャルファイルルート
- パスとルートの割当をコードで変更できる機能
- https://tanstack.com/router/latest/docs/framework/react/guide/virtual-file-routes
- ファイルベースルーティングの一部だけをバーチャルファイルルートにすることもできる
ディレクトリを()で囲むとパスとしては認識されなくなる
- ディレクトリをファイルをまとめるためだけに使えるようになる
- https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#pathless-route-group-directories
自動コード分割
- 自動コード分割の設定は
vite.config.ts
で行います
import { defineConfig } from "vite";import viteReact from "@vitejs/plugin-react";import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
// https://vitejs.dev/config/export default defineConfig({ plugins: [ TanStackRouterVite({ autoCodeSplitting: true, }), viteReact(), tailwindcss(), ], test: { globals: true, environment: "jsdom", },});
- コード分割の対象になるのは以下のコンポーネントだけです
- Route Component
- Error Component
- Pending Component
- Not-found Component
Not Found 対応
- ルートではなく、notFoundComponentを使う
-
main.tsx
をひらき、createRouter
のオプションにdefaultNotFoundComponent
を追加...// Create a new router instanceconst router = createRouter({routeTree,defaultNotFoundComponent: () => {return (<><div>ページが見つかりません</div><Link to="/">トップページへ</Link></>);},});... -
localhost:3000/hoge にアクセスし、ページが見つかりませんと表示されることを確認する
-
routes/posts/$postId.tsx
を開き、loader
でnotFound()をスローするimport { createFileRoute, notFound } from "@tanstack/react-router";export const Route = createFileRoute("/posts/$postId")({loader: () => {throw notFound();},component: RouteComponent,});function RouteComponent() {const { postId } = Route.useParams();return <div>Hello "/posts/$postId"! {postId}</div>;} -
http://localhost:3000/posts/123 にアクセスし、ページが見つかりませんと表示されることを確認する
-
routes/posts/$postId.tsx
に notFoundComponent を追加するimport { createFileRoute, notFound } from "@tanstack/react-router";export const Route = createFileRoute("/posts/$postId")({loader: () => {throw notFound();},component: RouteComponent,notFoundComponent: () => {return <div>記事が見つかりません</div>;},});function RouteComponent() {const { postId } = Route.useParams();return <div>Hello "/posts/$postId"! {postId}</div>;} -
http://localhost:3000/posts/123 にアクセスし、記事が見つかりませんと表示されることを確認する
-
beforeLoad
で notFound() をスローするよう変更するimport { createFileRoute, notFound } from "@tanstack/react-router";export const Route = createFileRoute("/posts/$postId")({beforeLoad: () => {throw notFound();},component: RouteComponent,notFoundComponent: () => {return <div>記事が見つかりません</div>;},});function RouteComponent() {const { postId } = Route.useParams();return <div>Hello "/posts/$postId"! {postId}</div>;} -
http://localhost:3000/posts/123 にアクセスし、レイアウトが消えていることを確認する
-
beforeLoad
を削除する
ルートローディングライフサイクル
__root.tsx
beforeLoad →$postId.tsx
beforeLoad__root.tsx
loader → component &$postId.tsx
loader → component
ページ遷移
-
routes/posts/index.tsx
にダミー記事へのリンクを追加しますimport { createFileRoute, Link } from "@tanstack/react-router";export const Route = createFileRoute("/posts/")({component: RouteComponent,});function RouteComponent() {return (<div className="h-full grid place-items-center"><ul><li><Link to="/posts/$postId" params={{ postId: "123" }}>記事 123へ</Link></li></ul></div>);} -
from
属性を追加し、to
を相対パスにします -
ページ切り替えリンクを追加します
import { createFileRoute, Link } from "@tanstack/react-router";export const Route = createFileRoute("/posts/")({component: RouteComponent,});function RouteComponent() {return (<div className="h-full grid place-items-center"><ul><li><Link from="/posts" to="./$postId" params={{ postId: "123" }}>記事 123へ</Link></li></ul><Link from="/posts" to="." search={{page: 2}} >次のページへ</Link></div>);} -
search
属性に関数を渡すように変更します...<Linkfrom="/posts"to="."search={(prev) => ({...prev,page: prev.page + 1,})}>次のページへ</Link>... -
パラメータの型と検証を追加します
...type PostsSearchProps = {page: number;};export const Route = createFileRoute("/posts/")({validateSearch: (search: Record<string, string>): PostsSearchProps => {return {page: Number(search?.page ?? 1),};},component: RouteComponent,});... -
ページ番号を表示する
...function RouteComponent() {const { page } = Route.useSearch();return (<div className="h-full grid place-items-center"><ul><li><Link from="/posts" to="./$postId" params={{ postId: "123" }}>記事 123へ</Link></li></ul><Linkfrom="/posts"to="."search={(prev) => ({...prev,page: prev.page + 1,})}>{page} 次のページへ</Link></div>);} -
<Header />
で型エラーが出ていることを確認します -
zodとzod-adapterをインストール
npm i zod @tanstack/zod-adapter -
validateParamsの処理をzodで置き換えます
import { createFileRoute, Link } from "@tanstack/react-router";import { zodValidator } from "@tanstack/zod-adapter";import { z } from "zod";const postsSearchParamsSchema = z.object({page: z.number().default(1),});export const Route = createFileRoute("/posts/")({validateSearch: zodValidator(postsSearchParamsSchema),component: RouteComponent,});... -
<Header />
の型エラーが消えていることを確認します
activeProps
-
<Header />
をひらき、ブログトップのリンクにactiveProps
を追加しますimport { Link } from "@tanstack/react-router";export const Header = () => {return (<div className="navbar bg-base-100 px-8"><div className="flex-1"><Linkto="/"activeProps={{className: "border-b-2 border-text",}}>ブログトップ</Link></div><div className="flex-none"><ul className="flex gap-4"><li><Link to="/posts">記事一覧</Link></li><li><Link to="/tags">タグ一覧</Link></li><li><Link to="/about">About</Link></li></ul></div></div></div>);}; -
共通のオプションを作って全てのメニューで展開します
import { Link } from "@tanstack/react-router";const navLinkOptions = {className: "pb-1",activeProps: {className: "border-b-2 border-text",},};export const Header = () => {return (<div className="navbar bg-base-100 px-8"><div className="flex-1"><Link to="/" {...navLinkOptions}>ブログトップ</Link></div><div className="flex-none"><ul className="flex gap-4"><li><Link to="/posts" {...navLinkOptions}>記事一覧</Link></li><li><Link to="/tags" {...navLinkOptions}>タグ一覧</Link></li><li><Link to="/about" {...navLinkOptions}>About</Link></li></ul></div></div>);};
ページ遷移に関するその他の機能
<Link />
以外に以下の方法でもページ遷移を行えますuseNavigate
フック-
フックを実行した戻り値の
navigate
関数を使ってページ遷移します。const navigate = useNavigate({from: '/post/$postId'})// ...navigate({ to: '/posts/$postId', params: { postId } })
-
<Navigate />
コンポーネントrouter.navigate
APIuseNavigate
フックの戻り値と同じ、ただしrouter
を参照できればどこでも使えます。
linkOption
関数を使うと<Link />
navigate
<Navigate />
で使える共通の属性をあらかじめ作成しておくことができます
データローダー
-
routes/post/$postId.tsx
にデータローダーを追加してコンソールを確認しますimport { createFileRoute } from "@tanstack/react-router";export const Route = createFileRoute("/posts/$postId")({loaderDeps: ({ search }) => {console.log("loaderDeps", search);return { hoge: "hoge" };},beforeLoad: ({ params, search }) => {console.log("beforeLoad", params, search);},loader: ({ params, deps }) => console.log("loader", params, deps),component: RouteComponent,notFoundComponent: () => {return <div>記事が見つかりません</div>;},});function RouteComponent() {const { postId } = Route.useParams();return <div>Hello "/posts/$postId"! {postId}</div>;} -
main.ts
に defaultPreload の設定を追加し、リンクにホバーしたときにコンソールを確認します...// Create a new router instanceconst router = createRouter({routeTree,defaultPreload: "intent",defaultNotFoundComponent: () => {return (<><div>ページが見つかりません</div><Link to="/">トップページへ</Link></>);},});...
それぞれの役割
- loaderDeps ・・・ searchパラメータを読み出してloaderにわたす
- beforLoad ・・・ 認証のチェック、ルートのリダイレクト
- loader ・・・ データ取得とキャッシュ
staleTime
- https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#️-some-important-defaults
- preloadStaleTimeのデフォルトは30秒
- staleTime のデフォルトは0、つまりルート遷移後preloadされたデータが表示されたあと、常にloaderが走る。
TanStack Queryとの連携
-
TanStack Queryをインストール
npm i @tanstack/react-query -
src/api/posts.ts
ファイルを作成し、ダミーのデータ取得API呼び出しを作成しますimport { queryOptions } from "@tanstack/react-query";const getPosts = async () => [{ id: 1, title: "title 1", body: "body 1" },{ id: 2, title: "title 2", body: "body 2" },{ id: 3, title: "title 3", body: "body 3" },];export const postsQueryOptions = queryOptions({queryKey: ["posts"],queryFn: getPosts,});const getPost = async (id: number) => {const data = await getPosts();return data.find((x) => x.id === id);};export const postQueryOptions = (postId: number) =>queryOptions({queryKey: ["post", postId],queryFn: () => getPost(postId),}); -
routes/__root.ts
を開き、コンテクスト付きルートに変更しますimport { createRootRouteWithContext, Outlet } from "@tanstack/react-router";import type { QueryClient } from "@tanstack/react-query";import { Header } from "../components/Header";import { Footer } from "../components/Footer";type RouterContext = {queryClient: QueryClient;};export const Route = createRootRouteWithContext<RouterContext>()({component: () => (<div className="h-dvh flex flex-col"><Header /><div className="grow p-8 grid place-items-center overflow-scroll"><Outlet /></div><Footer /></div>),}); -
main.tsx
を開き、クエリクライアントを初期化し、コンテクストに追加します- QueryClientProvider を追加していないことに留意
...import { QueryClient } from "@tanstack/react-query";...// Create a new router instanceconst router = createRouter({routeTree,defaultPreload: "intent",defaultNotFoundComponent: () => {return (<><div>ページが見つかりません</div><Link to="/">トップページへ</Link></>);},context: {queryClient: new QueryClient(),},defaultStaleTime: 0,defaultPreloadStaleTime: 0,});... -
routes/posts/index.tsx
でpostsデータを読み出します...import { postsQueryOptions } from "../../api/posts";...export const Route = createFileRoute("/posts/")({loader: ({ context }) =>context.queryClient.ensureQueryData(postsQueryOptions),validateSearch: zodValidator(postsSearchParamsSchema),component: RouteComponent,});function RouteComponent() {const { page } = Route.useSearch();const data = Route.useLoaderData();return (<div className="h-full grid place-items-center"><ul>{data.map((x) => (<li><Linkfrom="/posts"to="./$postId"params={{ postId: x.id.toString() }}>{x.title}</Link></li>))}</ul><Linkfrom="/posts"to="."search={(prev) => ({...prev,page: prev.page + 1,})}>{page} 次のページへ</Link></div>);} -
同様に
routes/posts/$postId.tsx
でpostデータを読み出しますimport { createFileRoute, notFound } from "@tanstack/react-router";import { postQueryOptions } from "../../api/posts";export const Route = createFileRoute("/posts/$postId")({loader: async ({ context, params }) => {const data = await context.queryClient.ensureQueryData(postQueryOptions(Number(params.postId)),);if (!data) throw notFound();return data;},component: RouteComponent,notFoundComponent: () => {return <div>記事が見つかりません</div>;},});function RouteComponent() {const { postId } = Route.useParams();const data = Route.useLoaderData();return (<div>Hello "/posts/{postId}"!<h1>{data.title}</h1><h2>{data.body}</h2></div>);}
- ロード中とエラーの表示はそれぞれ
pendingComponent
、errorComponent
を使います
認証
-
src/api/auth.ts
を作成しダミーの認証API呼び出しフックを作成します- Context - Provider を作成していないことに留意
import { useState } from "react";export const useAuth = () => {const [isAuth, setIsAuth] = useState(false);const login = async () => {setIsAuth(true);};const logout = async () => {setIsAuth(false);};return {isAuth,login,logout,};};export type Auth = ReturnType<typeof useAuth>; -
routes/__root.tsx
に Authの型を登録します...import type { Auth } from "../api/auth";type RouterContext = {queryClient: QueryClient;auth: Auth;};... -
main.tsx
で useAuth フックを呼び出しコンテクストに設定します...import { useAuth } from "./api/auth";...// Create a new router instanceconst router = createRouter({routeTree,defaultPreload: "intent",defaultNotFoundComponent: () => {return (<><div>ページが見つかりません</div><Link to="/">トップページへ</Link></>);},context: {queryClient: new QueryClient(),auth: undefined!,},defaultStaleTime: 0,});...const App = () => {const auth = useAuth();return <RouterProvider router={router} context={{ auth }} />;};// Render the appconst rootElement = document.getElementById("app")!;if (!rootElement.innerHTML) {const root = ReactDOM.createRoot(rootElement);root.render(<StrictMode><App /></StrictMode>,);} -
routes/admin.tsx
を作成し、beforeLoadで認証状態のチェックをしますimport { createFileRoute, redirect } from "@tanstack/react-router";export const Route = createFileRoute("/admin")({beforeLoad: ({ context, location }) => {if (!context.auth.isAuth)throw redirect({to: "/login",search: {redirect: location.href,},});},});- componentを省略すると
<Outlet />
をおいたのと同じになります
- componentを省略すると
-
routes/login.tsx
を作成しますimport { createFileRoute } from "@tanstack/react-router";type Search = {redirect?: string;};export const Route = createFileRoute("/login")({validateSearch: (search: Record<string, string>): Search => {return {redirect: search.redirect ?? "/",};},loader: ({ context }) => {return {login: context.auth.login,};},component: RouteComponent,});function RouteComponent() {const { login } = Route.useLoaderData();const { redirect } = Route.useSearch();const navigate = Route.useNavigate();return (<div><div>Hello "/login"!</div><buttonclassName="btn btn-outline"type="button"onClick={async () => {await login();navigate({ to: redirect });}}>login</button></div>);} -
localhost:3000/admin にアクセスし、 login にリダイレクトされることを確認します。
-
routes/admin/index.tsx
を作成しますimport { createFileRoute } from "@tanstack/react-router";export const Route = createFileRoute("/admin/")({component: RouteComponent,loader: ({ context }) => {return {logout: context.auth.logout,};},});function RouteComponent() {const { logout } = Route.useLoaderData();const navigate = Route.useNavigate();return (<div><div>Hello "/admin/"!</div><buttontype="button"className="btn btn-outline"onClick={async () => {await logout();navigate({ to: "/login", search: {redirect: "/admin"} });}}>logout</button></div>);} -
ログイン・ログアウトが動作することを確認します
その他の便利な機能
スクロール位置の復元
createRouter
の scrollRestoration
を使います。
-
main.tsx
を開き、scrollRestoration
オプションを足します...// Create a new router instanceconst router = createRouter({routeTree,scrollRestoration: true,defaultPreload: "intent",defaultNotFoundComponent: () => {return (<><div>ページが見つかりません</div><Link to="/">トップページへ</Link></>);},context: {queryClient: new QueryClient(),auth: undefined!,},defaultStaleTime: 0,});...
Navigation Blocking
ページ遷移の前に確認ダイアログを挟むことができます
-
routes/index.tsx
をひらき、useBlocker
フックを追加しますimport { createFileRoute, useBlocker } from "@tanstack/react-router";export const Route = createFileRoute("/")({component: App,});function App() {useBlocker({shouldBlockFn: () => {const shouldLeave = confirm("Are you sure you want to leave?");return !shouldLeave;},});return <div className="text-center">This is top route</div>;}
Eslintルール
https://tanstack.com/router/latest/docs/eslint/eslint-plugin-router
DevTools
https://tanstack.com/router/latest/docs/framework/react/devtools#devtools