Remix(React Router v7)入門:loader/actionでデータ取得と更新を1ルートに集める
Remix改めReact Router v7のloader/action入門。データ取得・更新、ネストルーティング、フォームの段階的強化、Next.jsとの違いを動くコードで解説。
「Remixの記事を読みながら写経してるのに、import文だけが全部赤い」
去年の僕がそれでした。@remix-run/react から Form を読もうとしてエラー。公式の最新ドキュメントを開いたら、そもそも react-router から読む書き方になっていて、頭が一瞬フリーズしました。
種を明かすと、いまのRemixは React Router v7 に合流しています。名前は変わっても、loaderでデータを読み、actionで書き換え、ルートごとにUIとエラー処理をまとめる——あの設計思想はそのまま生きている。むしろ強化されています。
この記事は、その新しいRemix=React Router v7を、動くコード1枚を軸に最短で体に入れるための入門です。
この記事の要点
- いまの「Remix」は実質 React Router v7のFramework Mode。新規なら
react-routerと@react-router/devを使う。古い@remix-run/*の記事はimportがずれる。 - loader は画面を描く前のデータ取得、action はフォーム送信などの更新。この2つを1ルートに同居させるのがRemixらしさのSEOで言う「核」。
- データの受け取りは
useEffectのfetchではなく、型付きのloaderDatapropsで受け取るのが現在の公式推奨。 <Form>はJavaScriptが死んでてもHTMLとして送信できる(progressive enhancement=段階的強化)。action後はloaderが自動で再取得される。- Next.jsとの違いは「ファイル名でルートが決まり Server Actions を使う」か「
routes.tsで明示し loader/action をルートに置く」か。発想が違う。
そもそもRemixとReact Routerは今どういう関係か
ここを誤解したまま進むと、コードが永遠に合いません。先に整理します。
公式ブログReact Router v7の発表では、Remixの良さをReact Router本体に取り込んだ、と明言されています。Remix v2のユーザーにはv7へのアップグレードが案内され、loader/action・routes.ts・型生成・静的プリレンダリングといった機能がReact Routerの「フレームワークモード」として提供される形になりました。つまり今から学ぶなら、Remixという独立製品を追うより、React Router公式ドキュメントを正にするのが正解です。
| 言葉 | 中身 | importの目印 |
|---|---|---|
| Remix v2(旧) | 旧来のフレームワーク。保守案件で残る | @remix-run/react, @remix-run/node |
| React Router v7 Framework Mode | いまのRemix。新規はこれ | react-router, @react-router/dev |
| React Router(ライブラリだけ) | ルーティングだけ使う旧来の使い方 | react-router-dom(v6時代の記憶) |
迷ったら指針は1つ。新規はFramework Mode、保守は既存方針に合わせる。この記事は新規前提でReact Router v7 Framework Modeを使います。
loaderとactionを一言でいうと
難しく考えないでください。レストランに例えます。
loader は、お客さん(ブラウザ)が席に着く前に厨房が用意しておく前菜です。画面を描画する前に、必要なデータをサーバー側で先に読んでおく。だから初期表示が速いし、ローディングのちらつきが減ります。
action は、お客さんが「これ注文」と伝票(フォーム)を出したときに厨房が受ける注文係です。データを書き換える操作を一手に引き受けます。
そして気持ちいいのが、注文が通った後の挙動。actionが終わると、そのページのloaderが自動でもう一度走るんです。「更新したのに画面が古いまま」を自分で書かなくていい。再取得(再検証)はフレームワークがやってくれます。
この「loaderで読む・actionで書く・終わったら自動で読み直す」のループが、Remix系の心臓部です。
コピペで動く:商品一覧+検索+追加を1ルートで
説明より動くコードです。create-react-router で雛形を作った前提で、1ファイルだけ貼れば「一覧表示・検索・新規追加」が動くルートを置きます。loaderとactionが同じファイルに同居している点に注目してください。
まず雛形を用意します。
npx create-react-router@latest rr-shop
cd rr-shop
npm install
npm run dev
次にルート定義です。app/routes.ts に1行足します。
// app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("products", "routes/products.tsx"),
] satisfies RouteConfig;
本体です。app/routes/products.tsx をこの内容にします。データは説明用にメモリ上の配列で持ちます(実務ではここをDBに差し替えるだけ)。
// app/routes/products.tsx
import { Form, Link } from "react-router";
import type { Route } from "./+types/products";
// --- 説明用の簡易データ層(本番はここをDBに置き換える)---
type Product = { id: string; name: string; price: number };
const db: Product[] = [
{ id: "starter", name: "スターターキット", price: 9800 },
{ id: "team", name: "チームパック", price: 29800 },
];
// loader: 画面を描く前にサーバーで走る。検索クエリで絞って返すだけ。
export async function loader({ request }: Route.LoaderArgs) {
const q = (new URL(request.url).searchParams.get("q") ?? "").toLowerCase();
const products = q
? db.filter((p) => p.name.toLowerCase().includes(q))
: db;
// UIに必要な形だけ返す。原価や内部メモは絶対に混ぜない。
return { q, products };
}
// action: フォーム送信を受けてデータを書き換える。サーバー側で必ず検証する。
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const name = String(form.get("name") ?? "").trim();
const price = Number(form.get("price"));
// HTMLのrequiredはブラウザ越し以外を素通しするので、ここで止める。
if (name.length < 2 || !Number.isFinite(price) || price <= 0) {
return { ok: false as const, error: "名前は2文字以上、価格は正の数で。" };
}
db.push({ id: crypto.randomUUID(), name, price });
// 何も返さなくてOK。action後にloaderが自動で再実行され一覧が更新される。
return { ok: true as const };
}
// 受け取りはuseEffectのfetchではなく、型付きのpropsで受ける(現在の公式推奨)。
export default function Products({ loaderData, actionData }: Route.ComponentProps) {
const { q, products } = loaderData;
return (
<main>
{/* React 19の組み込みタグでSEOもこのルートに同居させる */}
<title>商品一覧 | RR Shop</title>
<meta name="description" content="Claude Code向けの教材を一覧で比較できます。" />
<h1>商品一覧</h1>
{/* method=get の検索フォーム。送信するとURLにqが付きloaderが再実行される */}
<Form method="get" role="search">
<input name="q" defaultValue={q} placeholder="名前で検索" />
<button type="submit">検索</button>
</Form>
<ul>
{products.map((p) => (
<li key={p.id}>
<Link to={`/products?q=${encodeURIComponent(p.name)}`}>{p.name}</Link>
<strong> {p.price.toLocaleString()} 円</strong>
</li>
))}
</ul>
<h2>商品を追加</h2>
{/* method=post の追加フォーム。JSが無くてもHTMLとして送信できる */}
<Form method="post">
<input name="name" placeholder="商品名" />
<input name="price" type="number" placeholder="価格" />
<button type="submit">追加</button>
</Form>
{actionData?.ok === false ? <p role="alert">{actionData.error}</p> : null}
{actionData?.ok ? <p>追加しました。</p> : null}
</main>
);
}
npm run dev のまま /products を開けば、検索も追加もそのまま動きます。検索フォームを送るとURLに ?q= が付き、loaderが走り直して一覧が絞られる。追加フォームを送るとactionが検証して配列に積み、loaderが自動で再実行されて一覧に出る。この一連を自分でfetchやsetStateで書いていないのが要点です。
補足:以前よく見た
useLoaderData<typeof loader>()も今も動きます。ただv7の新規コードでは、上のようにRoute.ComponentPropsのloaderDataを使うのが公式の推し方です。./+types/productsの型はnpm run devやnpm run typecheckが自動生成します。
エラーは画面全体ではなくルート単位で受け止める
Remix系のうれしさの2つ目が、エラーの閉じ込めです。あるルートのloaderが失敗しても、画面全体を真っ白にせず、そのルートの ErrorBoundary だけを差し替えられます。
商品詳細を例にします。同じファイルに loader と ErrorBoundary を置きます。
// app/routes/products.$productId.tsx
import { data, isRouteErrorResponse, Link } from "react-router";
import type { Route } from "./+types/products.$productId";
const db = [{ id: "starter", name: "スターターキット" }];
export async function loader({ params }: Route.LoaderArgs) {
const product = db.find((p) => p.id === params.productId);
// 想定内の「無い」はstatus付きでthrowするとErrorBoundaryに届く
if (!product) throw data("見つかりませんでした", { status: 404 });
return { product };
}
export default function Detail({ loaderData }: Route.ComponentProps) {
return <h1>{loaderData.product.name}</h1>;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
// 404と予期せぬ例外を分ける。ユーザー向けにstackやsecretは出さない。
if (isRouteErrorResponse(error)) {
return (
<main>
<h1>{error.status === 404 ? "商品が見つかりません" : "読み込みに失敗"}</h1>
<Link to="/products">一覧へ戻る</Link>
</main>
);
}
return <h1>予期せぬエラーが発生しました</h1>;
}
ポイントは2つ。想定内の「無い」は data("...", { status: 404 }) をthrowしてboundaryに渡すこと。そして、ユーザー向けUIにスタックトレースや環境変数を絶対に出さないこと。僕はここを横着して、本番のエラー画面に error.stack を出していた時期があり、サーバーの内部パスが丸見えになって青ざめました。エラー処理の分類は別記事のエラー処理パターンに寄せてあるので、本格運用前に一度通すと安全です。
フォームの「段階的強化」が地味に効く
<Form> がただの <form> と違うのは、JavaScriptが読み込まれる前でもHTMLとして送信できる点です。これを段階的強化(progressive enhancement)と呼びます。回線が細い、JSのバンドルがまだ落ちてきていない、そんな瞬間でも問い合わせや検索が成立する。素のReactで onSubmit に全部ぶら下げると、JSが死んだ瞬間に何も送れなくなりますが、Remix系は土台がHTMLなので踏ん張れます。
送信中の表示はナビゲーションの状態から取ります。送信のたびにページ遷移を伴わせたくない(同じ画面で何個もボタンを押すような)場合は、useFetcher を使うと履歴を汚さずに更新だけできます。使い分けの目安はこうです。
- 一覧の検索・1画面1送信が中心 →
<Form>(遷移あり・URLに状態が残る) - いいねボタンや一覧内の個別削除など、画面に複数の操作 →
useFetcher(遷移なし)
どちらでも、送信後にloaderが自動で再検証される恩恵は同じです。
Next.jsとは何が違うのか
「Next.jsと結局どっちなの」とよく聞かれます。優劣ではなく発想の差です。表で並べます。
| 観点 | Remix / React Router v7 | Next.js App Router |
|---|---|---|
| ルート定義 | routes.ts で明示、または規約 | ファイル/フォルダ構成で決まる |
| データ取得 | route の loader(リクエスト単位) | Server Component で直接 await、fetch キャッシュ |
| データ更新 | route の action + <Form> | Server Actions(関数を直接呼ぶ) |
| エラー処理 | route の ErrorBoundary | error.tsx / not-found.tsx |
| 強み | HTMLフォーム前提の段階的強化、ルート集約 | RSC・キャッシュ・エコシステムの広さ |
ざっくり言うと、Next.jsは「コンポーネントの中でデータを取りに行く」発想、Remix系は「ルートという箱にloader/action/UI/エラーをまとめる」発想です。フォーム中心の業務アプリやSEOが要る画面はRemix系がはまりやすく、配信やキャッシュを攻めたい大規模はNext.jsが手厚い。Next.js側の作法はNext.jsフルスタック開発に、Reactそのものの設計はReact開発の進め方にまとめてあるので、比較しながら選ぶのがいいです。
Claude Codeに頼むときのコツ
最後に、これをClaude Codeに任せるときの実務メモです。
僕が最初にやらかしたのは「Remixでフォーム作って」とだけ投げたこと。返ってきたのは @remix-run/react の古いimportと、useEffect で手書きした fetch が混ざったコードでした。動くけど、思想がバラバラ。直す量のほうが多かったです。
効いたのは、前提を固定してから頼むことでした。
- 「React Router v7のFramework Mode、
react-routerから import。routes.tsでルートを明示」 - 「データ取得は
loader、更新はaction。受け取りはRoute.ComponentPropsのloaderData」 - 「
<Form>を使い、JSなしでも送信できる形に。actionでサーバー側検証も入れて」 - 「エラーは
ErrorBoundaryで404と例外を分け、stackやsecretは出さない」
この4点を渡すだけで、出てくる差分が一気に読みやすくなります。実装を急がせるより、ルート単位の責務を先に言語化して固定するほうが、結局速いというのが僕の結論です。レビュー観点や依頼文のテンプレを手元に揃えたい人は教材一覧も覗いてみてください。
よくある質問
Q. Remix v2のプロジェクトは作り直しですか? いいえ。まず動いているなら無理に作り直さなくて大丈夫です。新機能や型安全が欲しくなったタイミングで、公式のRemix v2アップグレードガイドに沿ってReact Router v7へ寄せるのが王道です。新規だけ最初からv7で始めましょう。
Q. loaderとgetServerSideProps(Next.js)は同じですか? 役割は似ていますが、loaderはルート単位で、actionと対になり、送信後に自動再検証される点が特徴です。「取得」と「更新」と「再取得」が1ルートに閉じる感覚は、Next.jsのページ単位の取得とは少し違います。
Q. useLoaderData はもう使えないのですか?
使えます。ただv7の新規コードでは Route.ComponentProps の loaderData propが公式の推奨です。既存コードの useLoaderData<typeof loader>() を急いで全部書き換える必要はありません。
Q. SEOのtitleやmetaはどう書くのが今風ですか?
React 19以降は、コンポーネント内に直接 <title> や <meta> を置けます。旧来の meta export も動きますが、meta 配列は親子でマージされず最後のルートが置き換える挙動なので、重複と不足のチェックを忘れずに。
Q. フォームが多い画面で遷移させたくないときは?
useFetcher を使います。<Form> と違ってページ遷移や履歴追加が起きず、いいねや個別削除のような背景更新に向きます。それでも送信後のloader再検証は効きます。
まとめ
Remixという名前に身構える必要はもうありません。中身はReact Router v7で、loaderで読み・actionで書き・終わったら自動で読み直す、という1本の筋が通っています。まずは上の products.tsx を1枚貼って、検索と追加が遷移なしの手書きfetchなしで動くのを体感してください。ルートという箱に責務を集める——この感覚さえ掴めば、Next.jsとの違いも、Claude Codeへの頼み方も、すっと腑に落ちるはずです。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。