ReactのルーティングをTanStack Routerで実装する|ページ遷移・動的ルート・レイアウトの基本


はじめに

前回の記事ではReact + ViteでコンポーネントベースのUI開発を始める方法として、JSXの書き方・props・useState・useEffect・カスタムフックを解説しました。今回はTanStack Routerを使ってReactアプリにページ遷移を実装する方法を解説します。TanStack Routerは型安全なルーティングライブラリで、TypeScriptとの相性が抜群です。ポートフォリオサイトのトップページ・ブログ一覧・個別記事ページといった複数ページの構成を実装しながら解説します。


1. Reactのルーティングとは

1.1 SPAのルーティングの仕組み

ReactはデフォルトではSPA(Single Page Application)として動作します。

従来のWebサイト(MPA):
/          → index.html を読み込む
/about     → about.html を読み込む
/blog      → blog.html を読み込む
→ ページ移動のたびにサーバーへリクエストが発生する

ReactのSPA:
/          → index.html(1ファイル)+ JSがURLを見てUIを切り替える
/about     → 同じindex.html + Reactがaboutコンポーネントを表示する
/blog      → 同じindex.html + Reactがblogコンポーネントを表示する
→ サーバーへのリクエストなしに画面が切り替わる(高速)

1.2 TanStack RouterとReact Routerの比較

比較TanStack RouterReact Router v6
TypeScript対応✅ 型安全なルーティング△ 型サポートあるが弱め
ファイルベースルーティング✅ 対応❌ 設定が必要
データローディング✅ 組み込み✅ loaderで対応
シェアード状態✅ 充実△ Context頼み
学習コスト中〜高低〜中
採用実績増加中多い

💡 今回はTanStack Routerを選びましたが、React Routerとの考え方は共通する部分が多いです: ルーティングの基本概念(パスの定義・動的ルート・ネストされたレイアウト)は同じです。どちらのライブラリを使っても、今回学ぶ概念は活かせます。


2. TanStack Routerのセットアップ

2.1 インストール

bash

# TanStack Routerをインストールする
npm install @tanstack/react-router

# Viteプラグインをインストールする(ファイルベースルーティング用)
npm install -D @tanstack/router-plugin

# ルート定義を生成するCLIツール
npm install -D @tanstack/router-cli

2.2 vite.config.tsにプラグインを追加する

typescript

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';

export default defineConfig( {
    plugins: [
        TanStackRouterVite(), // ← TanStack Routerプラグイン(reactの前に書く)
        react(),
    ],
    resolve: {
        alias: { '@': '/src' },
    },
} );

2.3 ファイルベースルーティングのフォルダ構成

src/
├── routes/                    ← ルートファイルを置くディレクトリ
│   ├── __root.tsx             ← ルートレイアウト(全ページ共通)
│   ├── index.tsx              ← / (トップページ)
│   ├── about.tsx              ← /about
│   ├── blog/
│   │   ├── index.tsx          ← /blog(ブログ一覧)
│   │   └── $postId.tsx        ← /blog/[postId](動的ルート)
│   └── works/
│       ├── index.tsx          ← /works(制作実績一覧)
│       └── $workId.tsx        ← /works/[workId]
├── components/
├── main.tsx
└── routeTree.gen.ts           ← 自動生成(触らない)

💡 $ で始まるファイル名が動的ルートになります: $postId.tsx は /blog/1・/blog/css-tips など、パスの一部が変動するルートです。$ の後の名前(postId)がパラメータ名になります。


3. ルーターの基本設定

3.1 main.tsxでルーターを設定する

tsx

// src/main.tsx
import { StrictMode }            from 'react';
import { createRoot }            from 'react-dom/client';
import { RouterProvider, createRouter } from '@tanstack/react-router';
import { routeTree }             from './routeTree.gen'; // 自動生成される

// ルーターのインスタンスを作成する
const router = createRouter( {
    routeTree,
    defaultPreload:     'intent',     // ホバー時にプリロードする
    defaultPreloadStaleTime: 0,
} );

// TypeScriptの型サポートのためにルーターの型を登録する
declare module '@tanstack/react-router' {
    interface Register {
        router: typeof router;
    }
}

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

3.2 ルートレイアウトを作る(__root.tsx)

// src/routes/__root.tsx
import { createRootRoute, Link, Outlet } from '@tanstack/react-router';

// ルートレイアウト(全ページに共通で表示される)
export const Route = createRootRoute( {
    component: RootLayout,
} );

function RootLayout() {
    return (
        <>
            {/* 全ページ共通のヘッダー */}
            <header className="site-header">
                <div className="header-inner">
                    <Link to="/" className="site-logo">
                        My Portfolio
                    </Link>

                    <nav className="global-nav">
                        <ul>
                            <li>
                                <Link
                                    to="/"
                                    activeProps={{ className: 'is-active' }}
                                    activeOptions={{ exact: true }}
                                >
                                    Home
                                </Link>
                            </li>
                            <li>
                                <Link
                                    to="/about"
                                    activeProps={{ className: 'is-active' }}
                                >
                                    About
                                </Link>
                            </li>
                            <li>
                                <Link
                                    to="/blog"
                                    activeProps={{ className: 'is-active' }}
                                >
                                    Blog
                                </Link>
                            </li>
                            <li>
                                <Link
                                    to="/works"
                                    activeProps={{ className: 'is-active' }}
                                >
                                    Works
                                </Link>
                            </li>
                        </ul>
                    </nav>
                </div>
            </header>

            {/* 各ページのコンテンツが表示される場所 */}
            <main className="site-main">
                <Outlet />
            </main>

            {/* 全ページ共通のフッター */}
            <footer className="site-footer">
                <p>&copy; {new Date().getFullYear()} My Portfolio</p>
            </footer>
        </>
    );
}

💡 <Outlet /> は子ルートのコンポーネントが描画される場所です: レイアウトコンポーネントの中で <Outlet /> を書いた場所に、現在のURLに対応するページコンポーネントが表示されます。


4. 各ページのルートを作る

4.1 トップページ(index.tsx)

// src/routes/index.tsx
import { createFileRoute, Link } from '@tanstack/react-router';

export const Route = createFileRoute( '/' )( {
    component: HomePage,
} );

function HomePage() {
    return (
        <div className="home-page">

            {/* ヒーローセクション */}
            <section className="hero">
                <h1 className="hero__title">
                    Web制作・React開発
                </h1>
                <p className="hero__subtitle">
                    HTML・CSS・JavaScript・WordPressから
                    React・TypeScriptまで幅広く対応します。
                </p>
                <div className="hero__actions">
                    <Link to="/works" className="btn btn--primary">
                        制作実績を見る
                    </Link>
                    <Link to="/about" className="btn btn--outline">
                        プロフィール
                    </Link>
                </div>
            </section>

            {/* 最新ブログ */}
            <section className="section">
                <h2 className="section__title">最新記事</h2>
                {/* ブログカードコンポーネントなどを配置 */}
                <div className="posts-grid">
                    {/* 後ほどloader機能で取得したデータを使う */}
                </div>
                <Link to="/blog" className="btn btn--secondary">
                    記事をもっと見る
                </Link>
            </section>

        </div>
    );
}

4.2 ブログ一覧ページ(blog/index.tsx)

// src/routes/blog/index.tsx
import { createFileRoute, Link } from '@tanstack/react-router';

// loaderで非同期データを取得する
export const Route = createFileRoute( '/blog/' )( {
    loader: async () => {
        const res  = await fetch( 'https://jsonplaceholder.typicode.com/posts?_limit=12' );
        const data = await res.json() as Post[];
        return { posts: data };
    },
    component: BlogIndexPage,
} );

interface Post {
    id:     number;
    title:  string;
    body:   string;
    userId: number;
}

function BlogIndexPage() {
    // loaderの戻り値を型安全に取得する
    const { posts } = Route.useLoaderData();

    return (
        <div className="blog-page">
            <header className="page-header">
                <h1 className="page-header__title">Blog</h1>
                <p className="page-header__desc">
                    Web制作の学習記録を発信しています。
                </p>
            </header>

            <div className="posts-grid">
                {posts.map( post => (
                    <article key={post.id} className="post-card">
                        <Link
                            to="/blog/$postId"
                            params={{ postId: String( post.id ) }}
                            className="post-card__link"
                        >
                            <h2 className="post-card__title">{post.title}</h2>
                            <p className="post-card__excerpt">
                                {post.body.slice( 0, 80 )}...
                            </p>
                        </Link>
                    </article>
                ) )}
            </div>
        </div>
    );
}

💡 TanStack Routerの loader はページ表示前にデータを取得する機能です: useEffect でデータ取得するのと違い、loader を使うとコンポーネントが表示されるときにはすでにデータが準備されています。ローディング状態の管理が不要になり、コードがシンプルになります。

4.3 ブログ個別ページ(blog/$postId.tsx)

// src/routes/blog/$postId.tsx
import { createFileRoute, Link, notFound } from '@tanstack/react-router';

interface Post {
    id:     number;
    title:  string;
    body:   string;
    userId: number;
}

export const Route = createFileRoute( '/blog/$postId' )( {
    // loaderでURLパラメータを使ってデータを取得する
    loader: async ( { params } ) => {
        const res = await fetch(
            `https://jsonplaceholder.typicode.com/posts/${params.postId}`
        );

        if ( !res.ok ) {
            // 404の場合はnotFound()を投げる
            throw notFound();
        }

        const post = await res.json() as Post;
        return { post };
    },
    component: BlogPostPage,
    notFoundComponent: PostNotFound, // 404時に表示するコンポーネント
} );

function BlogPostPage() {
    const { post }  = Route.useLoaderData();
    const { postId } = Route.useParams(); // URLパラメータを取得する

    return (
        <article className="single-post">

            <header className="single-post__header">
                <p className="breadcrumb">
                    <Link to="/">Home</Link>
                    {' / '}
                    <Link to="/blog">Blog</Link>
                    {' / '}
                    <span>記事{postId}</span>
                </p>
                <h1 className="single-post__title">{post.title}</h1>
            </header>

            <div className="single-post__content">
                <p>{post.body}</p>
            </div>

            <nav className="post-nav">
                <Link to="/blog" className="btn btn--outline">
                    &larr; 記事一覧に戻る
                </Link>
            </nav>

        </article>
    );
}

function PostNotFound() {
    return (
        <div className="not-found">
            <h2>記事が見つかりませんでした</h2>
            <Link to="/blog">記事一覧に戻る</Link>
        </div>
    );
}

4.4 Aboutページ(about.tsx)

// src/routes/about.tsx
import { createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute( '/about' )( {
    component: AboutPage,
} );

function AboutPage() {
    return (
        <div className="about-page">
            <h1>About</h1>
            <section className="profile">
                <img
                    src="/images/profile.jpg"
                    alt="プロフィール写真"
                    width={200}
                    height={200}
                />
                <div className="profile__info">
                    <h2>山田太郎</h2>
                    <p>Web制作を学習中のエンジニアです。</p>
                </div>
            </section>

            <section className="skills">
                <h2>スキル</h2>
                <ul className="skills__list">
                    {[
                        'HTML / CSS',
                        'JavaScript / TypeScript',
                        'React / Vite',
                        'WordPress',
                        'PHP',
                    ].map( skill => (
                        <li key={skill} className="skills__item">{skill}</li>
                    ) )}
                </ul>
            </section>
        </div>
    );
}

5. ナビゲーションと便利な機能

5.1 Linkコンポーネントの使い方

import { Link } from '@tanstack/react-router';

// 基本的なリンク
<Link to="/about">About</Link>

// 動的ルートへのリンク
<Link to="/blog/$postId" params={{ postId: String( post.id ) }}>
    {post.title}
</Link>

// アクティブ時のスタイルを適用する
<Link
    to="/blog"
    activeProps={{ className: 'nav-link is-active' }}
    inactiveProps={{ className: 'nav-link' }}
>
    Blog
</Link>

// 外部リンクは通常のaタグを使う
<a href="https://github.com/username" target="_blank" rel="noopener noreferrer">
    GitHub
</a>

5.2 プログラムによるナビゲーション

import { useNavigate, useRouter } from '@tanstack/react-router';

function ContactForm() {
    const navigate = useNavigate();

    const handleSubmit = async ( e: React.FormEvent ) => {
        e.preventDefault();
        // フォーム送信処理...
        await submitForm( formData );

        // 送信成功後にサンクスページに遷移する
        navigate( { to: '/thanks' } );
    };

    // ...
}

// 動的ルートへの遷移
navigate( {
    to: '/blog/$postId',
    params: { postId: '42' },
} );

// クエリパラメータ付きで遷移する
navigate( {
    to:     '/blog',
    search: { page: 2, category: 'css' },
} );

5.3 クエリパラメータを型安全に管理する

// src/routes/blog/index.tsx
import { createFileRoute, Link } from '@tanstack/react-router';
import { z } from 'zod'; // zodで検証する

// クエリパラメータのスキーマを定義する
const blogSearchSchema = z.object( {
    page:     z.number().catch( 1 ),
    category: z.string().optional(),
} );

export const Route = createFileRoute( '/blog/' )( {
    validateSearch: blogSearchSchema, // ← クエリパラメータを検証・型付けする
    loader: async ( { context } ) => {
        // searchパラメータを取得する
        const { page, category } = context;
        // ...
    },
    component: BlogIndexPage,
} );

function BlogIndexPage() {
    const { page, category } = Route.useSearch(); // 型安全なsearchパラメータ

    return (
        <div>
            <p>現在のページ:{page}</p>

            {/* ページネーション */}
            <Link to="/blog" search={{ page: page + 1 }}>
                次のページ →
            </Link>
        </div>
    );
}

6. ローディングとエラーの状態表示

6.1 ローディング中のスケルトン表示

// src/routes/blog/index.tsx
export const Route = createFileRoute( '/blog/' )( {
    loader:            async () => { /* ... */ },
    pendingComponent:  BlogSkeleton,    // ローディング中に表示する
    errorComponent:    BlogError,       // エラー時に表示する
    component:         BlogIndexPage,
} );

// ローディング中のスケルトンUI
function BlogSkeleton() {
    return (
        <div className="posts-grid">
            {Array.from( { length: 6 } ).map( ( _, i ) => (
                <div key={i} className="post-card post-card--skeleton">
                    <div className="skeleton skeleton--title" />
                    <div className="skeleton skeleton--text" />
                    <div className="skeleton skeleton--text" />
                </div>
            ) )}
        </div>
    );
}

// エラー時のUI
function BlogError( { error }: { error: Error } ) {
    return (
        <div className="error-state" role="alert">
            <h2>記事の読み込みに失敗しました</h2>
            <p>{error.message}</p>
            <button onClick={() => window.location.reload()}>
                再読み込みする
            </button>
        </div>
    );
}
css

/* スケルトンのCSS */
.post-card--skeleton {
    pointer-events: none;
}

.skeleton {
    background: linear-gradient(
        90deg,
        #1A2030 25%,
        #252D40 50%,
        #1A2030 75%
    );
    background-size: 200% 100%;
    border-radius:   4px;
    animation:       shimmer 1.5s infinite;
}

.skeleton--title { height: 24px; width: 70%; margin-bottom: 12px; }
.skeleton--text  { height: 16px; width: 90%; margin-bottom: 8px;  }

@keyframes shimmer {
    0%   { background-position: 200% 0; }
    100% { background-position: -200% 0; }
}

7. 404ページを設定する

tsx

// src/routes/__root.tsx に追記する
import { createRootRoute, Outlet, Link, NotFoundRoute } from '@tanstack/react-router';

export const Route = createRootRoute( {
    component:         RootLayout,
    notFoundComponent: NotFoundPage, // ← 404ページを設定する
} );

function NotFoundPage() {
    return (
        <div className="not-found-page">
            <h1>404</h1>
            <p>お探しのページは見つかりませんでした。</p>
            <Link to="/" className="btn btn--primary">
                トップページに戻る
            </Link>
        </div>
    );
}

8. 実践:ポートフォリオのルート構成例

src/routes/
├── __root.tsx          ← 共通ヘッダー・フッター・グローバルナビ
├── index.tsx           ← / トップページ(ヒーロー・最新記事・制作実績)
├── about.tsx           ← /about プロフィール・スキル
├── blog/
│   ├── index.tsx       ← /blog ブログ記事一覧(ページネーション付き)
│   └── $postId.tsx     ← /blog/1 個別記事
├── works/
│   ├── index.tsx       ← /works 制作実績一覧(フィルタ付き)
│   └── $workId.tsx     ← /works/portfolio-site 制作実績詳細
└── contact.tsx         ← /contact お問い合わせフォーム

9. 実践チェックリスト

✓ @tanstack/react-router と @tanstack/router-plugin がインストールできたか

✓ vite.config.ts にTanStackRouterViteプラグインを追加したか

✓ src/routes/ ディレクトリが作成されて routeTree.gen.ts が自動生成されているか

✓ __root.tsx でRootLayoutと <Outlet /> を設定したか

✓ <Link to="..."> でページ遷移が正しく動作するか確認したか

✓ アクティブなリンクにスタイルが適用されているか

✓ 動的ルート($postId.tsx)で Route.useParams() からパラメータを取得できるか

✓ loader でデータを取得して Route.useLoaderData() で受け取れるか

✓ pendingComponent でローディング状態が表示されるか

✓ 存在しないURLにアクセスすると404ページが表示されるか


まとめ

今回はTanStack Routerを使ってReactアプリにページ遷移を実装する方法を解説しました。ポイントをまとめると:

  • ReactのSPAではJavaScriptがURLを見てUIを切り替えるためページ移動時のサーバーリクエストが発生しない
  • TanStack Routerはファイルベースのルーティングで src/routes/ のファイル構成がそのままURLになる
  • __root.tsx でヘッダー・フッターなどの共通レイアウトを定義して <Outlet /> に子ページを表示する
  • <Link to="..."> でページ遷移・activeProps でアクティブなリンクのスタイルを設定する
  • $ で始まるファイル名が動的ルートで Route.useParams() でパラメータを取得する
  • loader でページ表示前にデータを取得して Route.useLoaderData() で受け取ると useEffect が不要になる
  • pendingComponent でローディングUI・errorComponent でエラーUIを設定できる
  • クエリパラメータはZodのスキーマで検証して型安全に管理できる

次の記事では、ReactアプリのグローバルなState管理ライブラリとしてJotai(または Zustand)を使う方法を解説します。お楽しみに!

コメント

タイトルとURLをコピーしました