react-router-v7 について
React Router v7のルートモジュールを構築するための開発スキルです。ルート単位でUI、データ読み込み、更新、エラー処理を整理し、型付きデータとプログレッシブエンハンスメントを扱います。
主な機能
- 01UIとデータをまとめるルートモジュール
- 02Loaderとactionの設計
- 03ルート単位のエラー処理
- 04フォームのプログレッシブエンハンスメント
- 05ルートロジックの個別テスト
活用シーン
- 01データ駆動のルートページ構築
- 02フォーム送信と検証の処理
- 03エラーを個別ルート内に限定
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ mindrally/ skills --skill react-router-v7インストール後、Claude Code で/react-router-v7を入力して読み込まれたか確認します。
詳細
React Router v7
React Router v7 のフレームワークモードでは、ルーティング、データ読み込み、変更処理をルートモジュールに統合します。各ルートモジュールは単一の URL セグメントを担当し、loader、action、default コンポーネント、ErrorBoundary をそれぞれエクスポートします。
ルートモジュールを構築するワークフロー
- ルートファイルを作成する — URL セグメントに対応するファイルを
app/routes/(または設定済みのルートディレクトリ)に追加します。レイアウトや動的セグメントには、ネストしたフォルダーやファイルを使用します。 - loader をエクスポートする — 描画前にルートで必要なデータを取得します。loader の先頭でルートパラメーターと検索パラメーターを検証します。
- コンポーネントをエクスポートする —
useLoaderData()で loader のデータを読み取り、UI を描画します。コンポーネントは表示に専念させます。 - action をエクスポートする —
<Form>またはuseFetcherからのPOST/PUT/DELETE送信を処理します。想定される失敗については例外をスローせず、検証エラーを含む型付きデータを返します。 - ErrorBoundary をエクスポートする — スローされたエラー(400、500、予期しない例外)をルート内で処理し、1 つのルートの失敗でアプリ全体が停止しないようにします。
- ナビゲーションを接続する — 変更処理には
<Form>、useFetcher、useSubmitを使用します。これにより、JavaScript が無効でもアプリが動作し、有効な場合は楽観的 UI も利用できます。 - モジュールをテストする — 描画されたコンポーネントツリーとは独立して、loader、action、検証エラー、エラーバウンダリーのテストを作成します。
ルートモジュール
- ルート UI、loader データ、action、メタデータ、エラーバウンダリーの境界としてルートモジュールを使用します。ルートモジュールは、コード分割とデータ読み込みの単位です。
- ルートモジュールは小さく保ちます。共有 UI は
app/components/に、再利用可能なデータアクセス処理はサービスレイヤー(例:app/services/posts.server.ts)に移します。 - プロジェクトでファイルベースのルーティングが設定されている場合、フレームワークモードではファイルベースのルーティングを優先します。高度なルートツリーや動的なルートツリーには、明示的な
routes.ts設定を使用します。 - 共有レイアウトや段階的な開示にはネストしたルートを使用します。親ルートの
<Outlet />が、一致した子ルートを描画します。 - 回復可能なルートエラーには、アプリ全体で単一の境界に頼らず、ルート固有の
ErrorBoundaryコンポーネントをエクスポートします。
// app/routes/posts.$postId.tsx
import { Form, useLoaderData, useNavigation } from "react-router";
import type { Route } from "./+types/posts.$postId";
import { getPost, updatePost } from "~/services/posts.server";
export async function loader({ params }: Route.LoaderArgs) {
const post = await getPost(params.postId);
if (!post) {
throw new Response("Not Found", { status: 404 });
}
return { post };
}
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const title = String(formData.get("title") ?? "");
if (title.trim().length === 0) {
return { ok: false as const, errors: { title: "Title is required" } };
}
await updatePost(params.postId, { title });
return { ok: true as const, errors: null };
}
export default function PostDetail() {
const { post } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<input name="title" defaultValue={post.title} />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</button>
</Form>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (error instanceof Response && error.status === 404) {
return <p>Post not found.</p>;
}
return <p>Something went wrong loading this post.</p>;
}
データの読み込み
- 描画前に利用可能にしておく必要があるルートデータには loader を使用します。これにより、ページの動作に不可欠なデータを待つ間、ローディングスピナーが表示されるのを避けられます。
- loader は決定論的で副作用のないものにします。loader の役割はデータの読み取りであり、書き込みではありません。
- パラメーターと検索パラメーターは、後続の処理で信頼するのではなく、loader の境界で検証します(例:
pageクエリパラメーターを解析し、範囲を確認します)。 - 型付きデータを返し、コンポーネントでフェッチ処理を重複させずに、
useLoaderData<typeof loader>()を通じてそのデータを利用します。 - 遅延読み込みやストリーミングのパターン(
defer、または<Await>に渡す Promise の返却)は、体感パフォーマンスを明確に向上させる場合にのみ使用します。デフォルトでは使用しません。
変更処理
- ルートでの変更処理やフォーム送信には action を使用します。action は、そのルートへの
POST/PUT/PATCH/DELETEナビゲーションや fetcher による送信時に実行されます。 - 段階的な機能強化には
<Form>、useFetcher、useSubmitを優先します。これらはクライアントサイドの JavaScript がなくても動作し、利用可能な場合は楽観的 UI を追加できます。 - 変更処理後に、影響を受けた loader データを再検証します。React Router は action の完了後、現在のルートツリー上の loader を自動的に再検証するため、
useEffectで手動の再フェッチを行うのは避けます。 - 検証エラーは、汎用例外をスローするのではなく、型付きの action データ(
return { errors })として処理します。スローするResponseは、実際の HTTP ステータスエラー(404、401、500)のために使用します。 - サーバー専用のシークレットや特権操作(API キー、データベースへの直接書き込み)をクライアント action に含めないでください。クライアントバンドルから除外される
.server.tsモジュール内で実行します。
// Optimistic UI with useFetcher
function LikeButton({ postId, liked }: { postId: string; liked: boolean }) {
const fetcher = useFetcher();
const optimisticLiked = fetcher.formData
? fetcher.formData.get("liked") === "true"
: liked;
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<input type="hidden" name="liked" value={String(!optimisticLiked)} />
<button type="submit">{optimisticLiked ? "Unlike" : "Like"}</button>
</fetcher.Form>
);
}
ナビゲーションと状態
- 共有可能な状態は URL パラメーターまたは検索パラメーターに保存します(フィルター、ページネーション、選択中のタブなど)。これにより、リンクや再読み込みで同じ表示を再現できます。
- 一時的な UI 状態(ドロップダウンの開閉、ホバー状態など)は、
useStateを使ってコンポーネント内に保持します。 - 別途グローバルなスピナーストアを用意するのではなく、保留中のナビゲーション状態(
useNavigation()、fetcher.state)を使って、楽観的 UI や読み込み中の UI を表示します。 - ルートローダーに属するデータには、グローバル状態(Redux/Zustand/Context)を使わないでください。ルーター独自のキャッシュと重複し、古くなる原因になります。
TypeScript とテスト
- ローダーとアクションの戻り値に型を指定します。パラメーターと戻り値の型を完全に推論できるよう、生成された
Route.LoaderArgs/Route.ActionArgs型(./+types/<route>から)を使います。 - ルートのローダー、アクション、バリデーション失敗、エラーバウンダリーを、コンポーネント全体のレンダリングに依存しない独立した単位としてテストします。
- 重要なフォームおよびナビゲーションのフローには、統合テスト(Playwright、ルーターハーネスを使った Testing Library など)を使います。
- コンポーネントの深い階層で
fetchをモックするのではなく、ルートとサービスの境界(app/services/*.server.ts)でネットワークと永続化をモックします。
よくある間違い
useEffect内でローダーのフェッチを重複させること。読み込みのウォーターフォールが発生し、ルートレベルでデータを読み込む利点が失われます。- ローダー内でデータを直接変更すること。ローダーは複数回実行される可能性があるため(再検証、プリフェッチ)、読み取り専用にする必要があります。
- ナビゲーションとレイアウトを維持するルート単位のエラーバウンダリーではなく、アプリ全体で共通の汎用
ErrorBoundaryでルートのエラーを隠すこと。 - 基になるローダーデータが保護されているのに、コンポーネント内だけで認証を確認すること(たとえば
useEffectからリダイレクトするなど)。保護されたデータが取得されたり一瞬表示されたりしないよう、ローダー内で確認してリダイレクトします。
コメント
Markdown 対応