Remix v3 / React Router v7完全ガイド: フレームワーク統合の新時代
はじめに
2024年、RemixとReact Routerの開発チームは、両プロジェクトを統合する大胆な決定を発表しました。Remix v3とReact Router v7は、実質的に同じコードベースを共有し、フルスタックフレームワークとクライアントルーティングライブラリの境界を曖昧にしました。
この記事では、Remix v3/React Router v7の新しいアーキテクチャ、主要な機能、そして既存プロジェクトの移行方法について、実践的に解説します。
Remix v3とReact Router v7の関係
統合の背景
- Remixはもともと有料製品として開発され、後にオープンソース化
- React RouterはReactエコシステムで最も人気のあるルーティングライブラリ
- どちらもReact Routerチームによって開発されている
- v3/v7で、両者は同じコア機能を共有する形に統合
位置づけ
React Router v7
├─ クライアントサイドルーティング(従来の用途)
└─ オプショナルなサーバーサイド機能(Remix的機能)
Remix v3
├─ React Router v7のフルスタックプリセット
└─ デフォルトでSSR、ファイルベースルーティングなど有効
つまり、React Router v7は「Remixの機能を選択的に使えるライブラリ」であり、Remixは「React Routerをフルスタック構成で使うフレームワーク」という関係です。
主要な新機能
1. React Router Vite Plugin
React Router v7の最大の変更点は、Viteベースのビルドシステムです。
// vite.config.ts
import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [reactRouter()],
});
これにより、以下が可能になります。
- SSR/SSG/SPAの柔軟な選択
- Viteの高速な開発体験
- プラグインエコシステムの活用
2. Type-Safe Routing
型安全なルーティングがビルトインでサポートされます。
// app/routes.ts
import { type RouteConfig, route, index } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('products/:id', 'routes/product.tsx'),
route('dashboard', 'routes/dashboard.tsx', [
route('settings', 'routes/dashboard.settings.tsx'),
route('profile', 'routes/dashboard.profile.tsx'),
]),
] satisfies RouteConfig;
// app/routes/product.tsx
import { useParams } from 'react-router';
export default function Product() {
// TypeScriptが自動的に型を推論
const { id } = useParams<{ id: string }>();
return <div>Product: {id}</div>;
}
3. Server Functions (旧Action/Loader)
サーバー関数の記述がより直感的になりました。
// app/routes/product.tsx
import { data } from 'react-router';
import type { Route } from './+types/product';
// Loaderの新しい書き方
export async function loader({ params }: Route.LoaderArgs) {
const product = await db.product.findUnique({
where: { id: params.id },
});
if (!product) {
throw data({ message: 'Not found' }, { status: 404 });
}
return { product };
}
// Actionの新しい書き方
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const productId = formData.get('productId') as string;
await db.product.delete({ where: { id: productId } });
return { success: true };
}
export default function Product({ loaderData }: Route.ComponentProps) {
const { product } = loaderData;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
4. Client Data (クライアントサイドデータフェッチ)
新しく追加された clientLoader と clientAction により、クライアントサイドのみのデータフェッチも可能です。
// app/routes/dashboard.tsx
import type { Route } from './+types/dashboard';
// サーバーサイドのloader
export async function loader() {
return { serverTime: new Date().toISOString() };
}
// クライアントサイドのloader(キャッシュ戦略など)
export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
// ローカルストレージをチェック
const cached = localStorage.getItem('dashboard-data');
if (cached) {
const data = JSON.parse(cached);
// キャッシュが新しければそれを返す
if (Date.now() - data.timestamp < 5 * 60 * 1000) {
return data;
}
}
// サーバーからデータを取得
const data = await serverLoader();
// キャッシュに保存
localStorage.setItem(
'dashboard-data',
JSON.stringify({ ...data, timestamp: Date.now() })
);
return data;
}
// クライアント専用のローダーとしてマーク
clientLoader.hydrate = true;
export default function Dashboard({ loaderData }: Route.ComponentProps) {
return <div>Server Time: {loaderData.serverTime}</div>;
}
5. Pre-rendering (Static Generation)
静的サイト生成が標準機能になりました。
// vite.config.ts
import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
reactRouter({
prerender: [
'/',
'/about',
'/contact',
// 動的ルートのプリレンダリング
async () => {
const products = await db.product.findMany();
return products.map((p) => `/products/${p.id}`);
},
],
}),
],
});
実践例: フルスタックアプリケーション
プロジェクトセットアップ
# React Router v7でプロジェクト作成
npx create-react-router@latest my-app
cd my-app
# または、Remixテンプレートで作成
npx create-remix@latest my-app
ファイル構成
my-app/
├── app/
│ ├── routes/
│ │ ├── _index.tsx
│ │ ├── products.$id.tsx
│ │ ├── products._index.tsx
│ │ └── api.products.ts
│ ├── root.tsx
│ └── routes.ts
├── public/
├── vite.config.ts
└── package.json
APIルート
// app/routes/api.products.ts
import { json } from 'react-router';
import type { Route } from './+types/api.products';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const limit = 20;
const products = await db.product.findMany({
skip: (page - 1) * limit,
take: limit,
});
return json({ products, page, limit });
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const intent = formData.get('intent');
if (intent === 'create') {
const name = formData.get('name') as string;
const description = formData.get('description') as string;
const product = await db.product.create({
data: { name, description },
});
return json({ product });
}
if (intent === 'delete') {
const id = formData.get('id') as string;
await db.product.delete({ where: { id } });
return json({ success: true });
}
throw json({ message: 'Invalid intent' }, { status: 400 });
}
データフェッチとフォーム
// app/routes/products._index.tsx
import { Form, useLoaderData, useFetcher } from 'react-router';
import type { Route } from './+types/products._index';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const products = await db.product.findMany({
skip: (page - 1) * 20,
take: 20,
});
return { products, page };
}
export default function ProductsList({ loaderData }: Route.ComponentProps) {
const { products, page } = loaderData;
const fetcher = useFetcher();
return (
<div>
<h1>Products</h1>
{/* 新規作成フォーム */}
<Form method="post" action="/api/products">
<input type="hidden" name="intent" value="create" />
<input name="name" placeholder="Product name" required />
<textarea name="description" placeholder="Description" />
<button type="submit">Create Product</button>
</Form>
{/* 商品リスト */}
<ul>
{products.map((product) => (
<li key={product.id}>
<a href={`/products/${product.id}`}>{product.name}</a>
{/* 削除ボタン(フェッチャーで楽観的UI) */}
<fetcher.Form method="post" action="/api/products">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={product.id} />
<button type="submit">Delete</button>
</fetcher.Form>
</li>
))}
</ul>
{/* ページネーション */}
<nav>
<a href={`/products?page=${page - 1}`}>Previous</a>
<span>Page {page}</span>
<a href={`/products?page=${page + 1}`}>Next</a>
</nav>
</div>
);
}
詳細ページ
// app/routes/products.$id.tsx
import { useLoaderData, Form, redirect, data } from 'react-router';
import type { Route } from './+types/products.$id';
export async function loader({ params }: Route.LoaderArgs) {
const product = await db.product.findUnique({
where: { id: params.id },
include: { reviews: true },
});
if (!product) {
throw data({ message: 'Product not found' }, { status: 404 });
}
return { product };
}
export async function action({ params, request }: Route.ActionArgs) {
const formData = await request.formData();
const comment = formData.get('comment') as string;
const rating = parseInt(formData.get('rating') as string);
await db.review.create({
data: {
productId: params.id,
comment,
rating,
},
});
return redirect(`/products/${params.id}`);
}
export default function Product({ loaderData }: Route.ComponentProps) {
const { product } = loaderData;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<h2>Reviews</h2>
<ul>
{product.reviews.map((review) => (
<li key={review.id}>
<p>Rating: {review.rating}/5</p>
<p>{review.comment}</p>
</li>
))}
</ul>
<Form method="post">
<h3>Add Review</h3>
<select name="rating" required>
<option value="5">5 Stars</option>
<option value="4">4 Stars</option>
<option value="3">3 Stars</option>
<option value="2">2 Stars</option>
<option value="1">1 Star</option>
</select>
<textarea name="comment" placeholder="Your review" required />
<button type="submit">Submit Review</button>
</Form>
</div>
);
}
移行ガイド
React Router v6からv7への移行
主な変更点:
createBrowserRouterから Vite プラグインベースへloader/actionの記述方法の変更- 型定義の自動生成
Before (v6)
// main.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
loader: rootLoader,
children: [
{
path: 'products/:id',
element: <Product />,
loader: productLoader,
},
],
},
]);
ReactDOM.createRoot(document.getElementById('root')!).render(
<RouterProvider router={router} />
);
After (v7)
// app/routes.ts
import { type RouteConfig, route, index } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('products/:id', 'routes/product.tsx'),
] satisfies RouteConfig;
// vite.config.ts
import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [reactRouter()],
});
Remix v2からv3への移行
Remix v3はReact Router v7をベースにしているため、多くの変更があります。
package.json
{
"dependencies": {
// Before
"@remix-run/node": "^2.x",
"@remix-run/react": "^2.x",
// After
"react-router": "^7.0.0",
"@react-router/node": "^7.0.0",
"@react-router/serve": "^7.0.0"
}
}
インポートパス
// Before
import { json, redirect } from '@remix-run/node';
import { useLoaderData, Form } from '@remix-run/react';
// After
import { data, redirect } from 'react-router';
import { useLoaderData, Form } from 'react-router';
ルート定義
// Before: Remix v2のファイルベースルーティング(自動)
// app/routes/products.$id.tsx
// After: 明示的なroutes.ts(推奨)
// app/routes.ts
export default [route('products/:id', 'routes/product.tsx')];
デプロイメント
Cloudflare Pages
// vite.config.ts
import { cloudflarePages } from '@react-router/dev/cloudflare';
import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [reactRouter(), cloudflarePages()],
});
Vercel
// vite.config.ts
import { vercel } from '@react-router/dev/vercel';
import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [reactRouter(), vercel()],
});
Node.js (Express)
// server.ts
import { createRequestHandler } from '@react-router/express';
import express from 'express';
const app = express();
app.use(express.static('public'));
app.all(
'*',
createRequestHandler({
build: await import('./build/server/index.js'),
})
);
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});
まとめ
Remix v3とReact Router v7の統合は、React開発の新しい章を開きました。フルスタックフレームワークとクライアントライブラリの境界が曖昧になり、開発者は必要に応じてサーバーサイド機能を段階的に導入できるようになりました。
主なメリット:
- Viteベースの高速な開発体験
- 型安全なルーティング
- 柔軟なレンダリング戦略(SSR/SSG/SPA)
- 統一されたデータフェッチパターン
- 豊富なデプロイメントオプション
既存のReact RouterやRemixプロジェクトからの移行も、段階的に行うことができます。新規プロジェクトでは、React Router v7から始めて、必要に応じてサーバーサイド機能を追加していくアプローチがおすすめです。