Remix v3からReact Router v7への移行ガイド
Remix v3とReact Router v7の統合により、両プロジェクトは実質的に同じものになります。本記事では、Remix v3からReact Router v7への移行について詳しく解説します。
Remix v3とReact Router v7の統合
統合の背景
RemixとReact Routerは元々同じチームによって開発されており、React Router v7ではRemixの機能を完全に統合します。
- Remix v3: 最後のRemixメジャーバージョン
- React Router v7: Remixの機能を含む次世代ルーター
- 統一アーキテクチャ: 同じコンパイラとランタイム
主な変更点
// Remix v3
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export async function loader() {
return json({ data: 'value' });
}
// React Router v7
import { json } from 'react-router';
import { useLoaderData } from 'react-router';
export async function loader() {
return json({ data: 'value' });
}
Breaking Changes
1. インポートパスの変更
すべてのRemix固有のインポートをReact Routerに変更します。
// ❌ Remix v3
import { json, redirect } from '@remix-run/node';
import { Form, useLoaderData, useActionData } from '@remix-run/react';
import { useNavigate } from '@remix-run/react';
// ✅ React Router v7
import { json, redirect } from 'react-router';
import { Form, useLoaderData, useActionData } from 'react-router';
import { useNavigate } from 'react-router';
2. パッケージの統合
// package.json
{
"dependencies": {
// ❌ Remix v3
"@remix-run/node": "^3.0.0",
"@remix-run/react": "^3.0.0",
"@remix-run/serve": "^3.0.0",
// ✅ React Router v7
"react-router": "^7.0.0"
}
}
3. 設定ファイルの変更
// ❌ remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
appDirectory: 'app',
assetsBuildDirectory: 'public/build',
serverBuildPath: 'build/index.js',
publicPath: '/build/',
};
// ✅ react-router.config.ts
import type { Config } from 'react-router';
export default {
appDirectory: 'app',
buildDirectory: 'build',
publicPath: '/build/',
} satisfies Config;
ルーティングの変更
1. ファイルベースルーティング
基本的なファイル構造は同じですが、設定が統合されます。
app/
├── routes/
│ ├── _index.tsx # /
│ ├── about.tsx # /about
│ ├── blog._index.tsx # /blog
│ ├── blog.$slug.tsx # /blog/:slug
│ └── dashboard/
│ ├── _layout.tsx # Layout
│ ├── index.tsx # /dashboard
│ └── settings.tsx # /dashboard/settings
2. ルート定義
// ❌ Remix v3: remix.config.js
export default {
routes(defineRoutes) {
return defineRoutes((route) => {
route('/posts', 'routes/posts/index.tsx');
route('/posts/:id', 'routes/posts/$id.tsx');
});
},
};
// ✅ React Router v7: react-router.config.ts
import { type RouteConfig } from 'react-router';
export default [
{
path: '/posts',
file: 'routes/posts/index.tsx',
},
{
path: '/posts/:id',
file: 'routes/posts/$id.tsx',
},
] satisfies RouteConfig[];
3. ネストされたルート
// routes/dashboard._layout.tsx
import { Outlet } from 'react-router';
export default function DashboardLayout() {
return (
<div className="dashboard">
<nav>{/* サイドバー */}</nav>
<main>
<Outlet />
</main>
</div>
);
}
// routes/dashboard.index.tsx
export default function DashboardIndex() {
return <h1>Dashboard Home</h1>;
}
// routes/dashboard.settings.tsx
export default function DashboardSettings() {
return <h1>Settings</h1>;
}
データローディングの変更
1. loaderの型定義
// ❌ Remix v3
import type { LoaderFunctionArgs } from '@remix-run/node';
export async function loader({ request, params }: LoaderFunctionArgs) {
// ...
}
// ✅ React Router v7
import type { LoaderFunctionArgs } from 'react-router';
export async function loader({ request, params }: LoaderFunctionArgs) {
// ...
}
2. データの取得
// routes/posts.$id.tsx
import { json, type LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.post.findUnique({
where: { id: params.id },
});
if (!post) {
throw new Response('Not Found', { status: 404 });
}
return json({ post });
}
export default function Post() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
3. 複数データの並列取得
import { json } from 'react-router';
import { useLoaderData } from 'react-router';
export async function loader() {
const [posts, categories, featured] = await Promise.all([
db.post.findMany(),
db.category.findMany(),
db.post.findMany({ where: { featured: true } }),
]);
return json({ posts, categories, featured });
}
export default function BlogIndex() {
const { posts, categories, featured } = useLoaderData<typeof loader>();
return (
<div>
<FeaturedPosts posts={featured} />
<Categories categories={categories} />
<PostList posts={posts} />
</div>
);
}
アクションの変更
1. フォーム処理
// routes/posts.new.tsx
import { json, redirect, type ActionFunctionArgs } from 'react-router';
import { Form, useActionData } from 'react-router';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get('title');
const content = formData.get('content');
if (!title || !content) {
return json({ error: 'Title and content are required' }, { status: 400 });
}
const post = await db.post.create({
data: { title, content },
});
return redirect(`/posts/${post.id}`);
}
export default function NewPost() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
{actionData?.error && <div className="error">{actionData.error}</div>}
<input type="text" name="title" placeholder="Title" />
<textarea name="content" placeholder="Content" />
<button type="submit">Create Post</button>
</Form>
);
}
2. 楽観的更新
import { useFetcher } from 'react-router';
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const fetcher = useFetcher();
const likes = fetcher.formData
? Number(fetcher.formData.get('likes'))
: initialLikes;
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<input type="hidden" name="likes" value={likes + 1} />
<button type="submit">
❤️ {likes}
</button>
</fetcher.Form>
);
}
エラーハンドリング
1. ErrorBoundary
// routes/posts.$id.tsx
import { useRouteError, isRouteErrorResponse } from 'react-router';
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div>
<h1>Error!</h1>
<p>{error?.message || 'Unknown error'}</p>
</div>
);
}
export default function Post() {
// ...
}
2. グローバルエラーハンドリング
// app/root.tsx
import { Outlet, useRouteError, isRouteErrorResponse } from 'react-router';
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<html>
<head>
<title>{error.status} Error</title>
</head>
<body>
<h1>{error.status}</h1>
<p>{error.data}</p>
</body>
</html>
);
}
return (
<html>
<head>
<title>Application Error</title>
</head>
<body>
<h1>Application Error</h1>
<pre>{error?.message}</pre>
</body>
</html>
);
}
export default function Root() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<Outlet />
</body>
</html>
);
}
メタデータとSEO
1. メタタグの定義
// routes/posts.$id.tsx
import type { MetaFunction } from 'react-router';
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
{ title: data.post.title },
{ name: 'description', content: data.post.excerpt },
{ property: 'og:title', content: data.post.title },
{ property: 'og:description', content: data.post.excerpt },
{ property: 'og:image', content: data.post.image },
];
};
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.post.findUnique({
where: { id: params.id },
});
return json({ post });
}
export default function Post() {
// ...
}
2. 動的メタタグ
import type { MetaFunction } from 'react-router';
export const meta: MetaFunction = ({ data, params }) => {
if (!data) {
return [{ title: 'Post Not Found' }];
}
const { post } = data;
return [
{ title: `${post.title} | My Blog` },
{ name: 'description', content: post.excerpt },
{ property: 'og:type', content: 'article' },
{ property: 'og:title', content: post.title },
{ property: 'og:url', content: `https://myblog.com/posts/${params.id}` },
{ property: 'article:published_time', content: post.createdAt },
];
};
移行ステップ
1. 依存関係の更新
# Remixパッケージをアンインストール
npm uninstall @remix-run/node @remix-run/react @remix-run/serve
# React Router v7をインストール
npm install react-router@7
2. インポートの一括変更
# すべてのファイルでインポートを置換
find app -type f -name "*.tsx" -exec sed -i '' 's/@remix-run\/node/react-router/g' {} +
find app -type f -name "*.tsx" -exec sed -i '' 's/@remix-run\/react/react-router/g' {} +
3. 設定ファイルの更新
// react-router.config.ts
import type { Config } from 'react-router';
export default {
// 既存のremix.config.jsの設定を移行
appDirectory: 'app',
buildDirectory: 'build',
publicPath: '/build/',
serverModuleFormat: 'esm',
} satisfies Config;
4. 型チェック
# TypeScriptの型エラーをチェック
npm run typecheck
# ビルドを実行
npm run build
まとめ
Remix v3からReact Router v7への移行は、主に以下の変更が必要です。
- インポートパス:
@remix-run/*からreact-routerへ - 設定ファイル:
remix.config.jsからreact-router.config.tsへ - パッケージ統合: 複数のパッケージから単一のパッケージへ
- API統一: RemixとReact Routerの機能が完全統合
移行は比較的シンプルで、多くの場合はインポートパスの変更が中心となります。React Router v7は、Remixの優れた開発者体験を標準のReact Routerに統合し、より広いエコシステムでの利用を可能にします。