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

スクリーンショット 2025-02-24 0.07.28.png スクリーンショット 2025-02-24 0.07.17.png

ファイル構成

.
├── index.html
├── public
├── src
│   ├── main.tsx
│   ├── routeTree.gen.ts
│   ├── routes
│   │   ├── __root.tsx
│   │   └── index.tsx
│   └── styles.css
├── tsconfig.json
└── vite.config.js

TailwindcssとDaisyUIのインストール

npm i -D tailwindcss @tailwindcss/vite daisyui

開発用サーバー立ち上げ

npm start

レイアウトの追加

  1. __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>
    ),
    });

ヘッダーとフッターの作成

  1. 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>
    );
    };
  2. 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>
    );
    };
  3. __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>
    ),
    });

ルートの作成

  1. routes/posts/index.tsx を作成すると、自動で以下の内容が書き込まれます

    import { createFileRoute } from '@tanstack/react-router'
    export const Route = createFileRoute('/posts/')({
    component: RouteComponent,
    })
    function RouteComponent() {
    return <div>Hello "/posts/"!</div>
    }
  2. <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>
    );
    };
  3. タグ一覧とAboutで型エラーが出ていることを確認します

  4. routes/tags.tsxroutes/about.tsx を新規作成し、エラーを解消します

パス区切りはディレクトリと . が使えます

index.tsxroute.tsx の違い

ダイナミックルートの作成

  1. 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>;
    }
  2. localhost:3000/posts/123 にアクセスし、postIdが表示されることを確認します

キャッチオールルート

その他のルート

パスレスルート

ネスト解除ルート

無視されるルート

APIルート

バーチャルファイルルート

ディレクトリを()で囲むとパスとしては認識されなくなる

自動コード分割

  1. 自動コード分割の設定は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",
},
});

Not Found 対応

  1. main.tsx をひらき、createRouter のオプションに defaultNotFoundComponent を追加

    ...
    // Create a new router instance
    const router = createRouter({
    routeTree,
    defaultNotFoundComponent: () => {
    return (
    <>
    <div>ページが見つかりません</div>
    <Link to="/">トップページへ</Link>
    </>
    );
    },
    });
    ...
  2. localhost:3000/hoge にアクセスし、ページが見つかりませんと表示されることを確認する

  3. 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>;
    }
  4. http://localhost:3000/posts/123 にアクセスし、ページが見つかりませんと表示されることを確認する

  5. 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>;
    }
  6. http://localhost:3000/posts/123 にアクセスし、記事が見つかりませんと表示されることを確認する

  7. 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>;
    }
  8. http://localhost:3000/posts/123 にアクセスし、レイアウトが消えていることを確認する

  9. beforeLoad を削除する

ルートローディングライフサイクル

https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#the-route-loading-lifecycle

ページ遷移

  1. 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>
    );
    }
  2. from 属性を追加し、 to を相対パスにします

  3. ページ切り替えリンクを追加します

    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>
    );
    }
  4. search 属性に関数を渡すように変更します

    ...
    <Link
    from="/posts"
    to="."
    search={(prev) => ({
    ...prev,
    page: prev.page + 1,
    })}
    >
    次のページへ
    </Link>
    ...
  5. パラメータの型と検証を追加します

    ...
    type PostsSearchProps = {
    page: number;
    };
    export const Route = createFileRoute("/posts/")({
    validateSearch: (search: Record<string, string>): PostsSearchProps => {
    return {
    page: Number(search?.page ?? 1),
    };
    },
    component: RouteComponent,
    });
    ...
  6. ページ番号を表示する

    ...
    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>
    <Link
    from="/posts"
    to="."
    search={(prev) => ({
    ...prev,
    page: prev.page + 1,
    })}
    >
    {page} 次のページへ
    </Link>
    </div>
    );
    }
  7. <Header /> で型エラーが出ていることを確認します

  8. zodとzod-adapterをインストール

    npm i zod @tanstack/zod-adapter
  9. 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,
    });
    ...
  10. <Header /> の型エラーが消えていることを確認します

activeProps

  1. <Header /> をひらき、ブログトップのリンクに activeProps を追加します

    import { Link } from "@tanstack/react-router";
    export const Header = () => {
    return (
    <div className="navbar bg-base-100 px-8">
    <div className="flex-1">
    <Link
    to="/"
    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>
    );
    };
  2. 共通のオプションを作って全てのメニューで展開します

    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>
    );
    };

ページ遷移に関するその他の機能

データローダー

  1. 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>;
    }
  2. main.ts に defaultPreload の設定を追加し、リンクにホバーしたときにコンソールを確認します

    ...
    // Create a new router instance
    const router = createRouter({
    routeTree,
    defaultPreload: "intent",
    defaultNotFoundComponent: () => {
    return (
    <>
    <div>ページが見つかりません</div>
    <Link to="/">トップページへ</Link>
    </>
    );
    },
    });
    ...

それぞれの役割

staleTime

TanStack Queryとの連携

  1. TanStack Queryをインストール

    npm i @tanstack/react-query
  2. 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),
    });
  3. 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>
    ),
    });
  4. main.tsx を開き、クエリクライアントを初期化し、コンテクストに追加します

    • QueryClientProvider を追加していないことに留意
    ...
    import { QueryClient } from "@tanstack/react-query";
    ...
    // Create a new router instance
    const router = createRouter({
    routeTree,
    defaultPreload: "intent",
    defaultNotFoundComponent: () => {
    return (
    <>
    <div>ページが見つかりません</div>
    <Link to="/">トップページへ</Link>
    </>
    );
    },
    context: {
    queryClient: new QueryClient(),
    },
    defaultStaleTime: 0,
    defaultPreloadStaleTime: 0,
    });
    ...
  5. 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>
    <Link
    from="/posts"
    to="./$postId"
    params={{ postId: x.id.toString() }}
    >
    {x.title}
    </Link>
    </li>
    ))}
    </ul>
    <Link
    from="/posts"
    to="."
    search={(prev) => ({
    ...prev,
    page: prev.page + 1,
    })}
    >
    {page} 次のページへ
    </Link>
    </div>
    );
    }
  6. 同様に 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>
    );
    }

認証

  1. 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>;
  2. routes/__root.tsx に Authの型を登録します

    ...
    import type { Auth } from "../api/auth";
    type RouterContext = {
    queryClient: QueryClient;
    auth: Auth;
    };
    ...
  3. main.tsx で useAuth フックを呼び出しコンテクストに設定します

    ...
    import { useAuth } from "./api/auth";
    ...
    // Create a new router instance
    const 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 app
    const rootElement = document.getElementById("app")!;
    if (!rootElement.innerHTML) {
    const root = ReactDOM.createRoot(rootElement);
    root.render(
    <StrictMode>
    <App />
    </StrictMode>,
    );
    }
  4. 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 /> をおいたのと同じになります
  5. 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>
    <button
    className="btn btn-outline"
    type="button"
    onClick={async () => {
    await login();
    navigate({ to: redirect });
    }}
    >
    login
    </button>
    </div>
    );
    }
  6. localhost:3000/admin にアクセスし、 login にリダイレクトされることを確認します。

  7. 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>
    <button
    type="button"
    className="btn btn-outline"
    onClick={async () => {
    await logout();
    navigate({ to: "/login", search: {redirect: "/admin"} });
    }}
    >
    logout
    </button>
    </div>
    );
    }
  8. ログイン・ログアウトが動作することを確認します

その他の便利な機能

スクロール位置の復元

createRouterscrollRestoration を使います。

  1. main.tsx を開き、scrollRestoration オプションを足します

    ...
    // Create a new router instance
    const router = createRouter({
    routeTree,
    scrollRestoration: true,
    defaultPreload: "intent",
    defaultNotFoundComponent: () => {
    return (
    <>
    <div>ページが見つかりません</div>
    <Link to="/">トップページへ</Link>
    </>
    );
    },
    context: {
    queryClient: new QueryClient(),
    auth: undefined!,
    },
    defaultStaleTime: 0,
    });
    ...

ページ遷移の前に確認ダイアログを挟むことができます

  1. 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