React

React Routerのaction・loaderの使い方|clientLoaderとの違い・再検証・バリデーション(v8対応)

React Routerのaction・loaderの使い方|clientLoaderとの違い・再検証・バリデーション(v8対応)

React Routerでは、ルートのデータを読むのが loader、フォーム送信などで書き込むのが action です。action が成功すると、画面上の loader は自動で再実行されます。ただし、入力エラーで data() に400を付けて返したときは再実行されません。名前の似た clientLoader と clientAction はブラウザだけで動く別のAPIです。この記事では、最新版のreact-router 8.5.1(2026年10月27日公開)で4つのAPIを動かし、確かめた挙動をコード付きで整理します。

まとめ:actionとloaderの要点

  • loader=読み取り、action=書き込み。Framework Modeではどちらもサーバで実行され、クライアントバンドルから除去される
  • clientLoader / clientAction はブラウザ専用。action と clientAction を両方定義すると clientAction が優先される
  • actionが2xxで終わると、画面上のloaderが自動で再実行される。data(…, { status: 400 }) で返した入力エラーでは再実行されない
  • 入力エラーは data() で返して画面に出し、成功したら redirect() で一覧へ戻す。想定外のエラーはErrorBoundaryに任せる
  • 8.3.1と7.18.3(2026年8月28日)から、actionの送信元チェックがスキーム(http/https)まで照合するようになった。TLSをプロキシで終端している構成は400になることがある

loaderとclientLoaderの実行タイミングの違い

サーバだけで動くloaderとRoute.LoaderArgs

公式のData Loadingガイドによると、loader はサーバ(またはプリレンダリング時のビルド)で動き、クライアントバンドルから削除されます。DBクライアントやAPIキーを直接書いても、ブラウザへは出力されません。クライアント側で画面を移るときは、React Routerが自動で fetch を送って呼び出します。遷移のたびにネットワーク往復が1回起きる前提で設計してください。

import type { Route } from "./+types/product";

export async function loader({ params }: Route.LoaderArgs) {
  return db.getProduct(params.pid);
}

export default function Product({ loaderData }: Route.ComponentProps) {
  return <h1>{loaderData.name}</h1>;
}

戻り値はプレーンなオブジェクトで構いません。Framework Modeでは、コンポーネントは useLoaderData を呼ばずにpropsの loaderData で受け取るのが標準です。

ブラウザで動くclientLoaderとHydrateFallback

clientLoader はブラウザだけで実行されます。SPAとして組む場合や、ブラウザにしか無いトークンで外部APIを叩く場合はこちらを使います。loader を置かず clientLoader だけを使う場合、初回ロードでは clientLoader が終わるまでルートコンポーネントが描画されません。その間の空白を埋めるため、同じファイルから HydrateFallback をexportします。

export async function clientLoader({ params }: Route.ClientLoaderArgs) {
  const res = await fetch("/api/products/" + params.pid);
  return res.json();
}

export function HydrateFallback() {
  return <p>読み込み中です</p>;
}

両方定義したときの既定動作とserverLoaderによる合成

両方exportした場合の既定は「初回SSR(またはプリレンダリング)は loader、以降のクライアント遷移は clientLoader」です。初回のハイドレーションでも clientLoader を走らせたいときは clientLoader.hydrate = true as const; を足します。as const を省くと loaderData の型推論が崩れます。

clientLoader の引数には serverLoader が渡るので、サーバのデータにブラウザで取った値を足す合成ができます。

export async function clientLoader({ params, serverLoader }: Route.ClientLoaderArgs) {
  const server = await serverLoader();
  const stock = await fetch("/api/stock/" + params.pid).then((r) => r.json());
  return { ...server, ...stock };
}

クライアント遷移のときは、serverLoader() を呼ぶたびにサーバ往復が1回発生します。clientLoader を足す理由は、ブラウザにしか無い値との合成、ブラウザ側のキャッシュ、BFFを経由せずに外部APIへ直接行く、のどれかがある場合に絞り、理由なくページ全体の取得を clientLoader へ移すのは避けてください。ルート定義の書き方から確認したい場合は、React Router v8の使い方|meta・title設定と基本ルーティング、v7からの変更点で全体像を押さえられます。

actionの実装:Form送信・入力エラー・redirect

Formからactionを呼ぶ最小構成

<Form method="post"> の送信先は、既定では同じルートの action です。受け取りはWeb標準どおり request.formData() で、戻り値はコンポーネントの actionData に入ります。

import { Form } from "react-router";
import type { Route } from "./+types/project";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  return db.updateProject({ title: formData.get("title") });
}

export default function Project({ actionData }: Route.ComponentProps) {
  return (
    <Form method="post">
      <input type="text" name="title" />
      <button type="submit">保存</button>
      {actionData ? <p>{actionData.title} を更新しました</p> : null}
    </Form>
  );
}

data()による入力エラーの返却と400ステータス

入力チェックに失敗したら、例外を投げずに data() でエラー内容を返します。公式のForm Validationガイドは、400を付ける理由を「2xxのときだけページのデータが再検証されるため、400を返すと通常の再検証を防げる」と説明しています。入力ミスのたびに画面上の全loaderを取り直す無駄がなくなります。

import { data, redirect } from "react-router";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const title = String(formData.get("title") ?? "");
  if (title.trim() === "") {
    return data({ errors: { title: "タイトルは必須です" } }, { status: 400 });
  }
  await db.createProject({ title });
  return redirect("/projects");
}

// コンポーネント側
{actionData?.errors?.title ? <em>{actionData.errors.title}</em> : null}

成功時のredirectと、ErrorBoundaryに任せるエラー

保存に成功したら redirect() で一覧や詳細へ移します。送信後の画面でリロードしてもPOSTが再送されず、移動先のloaderが最新のデータを取ります。

DB障害のような想定外のエラーは、actionでcatchせずにそのまま投げ、ルートの ErrorBoundary に出させます。公式のError Boundaryガイドは、制御フローのためにわざとエラーを投げることを勧めていません。例外は404のように、ページを描画できないときの throw data("Record Not Found", { status: 404 }) です。入力エラーは return、描画できない状態は throw、と分けて考えます。

action後の自動再検証の実行条件

action結果ごとの再検証の実測

「actionの後は全loaderが再実行される」という説明は、成功したときだけ当てはまります。Node.js 26.5.0・react-router 8.5.1・React 19.3.1のData Mode(createMemoryRouter)で、親ルートと編集ルートにloaderを置き、actionの結果を変えて呼び出し回数を数えました。

actionの結果 actionData ErrorBoundary 親loader 同ルートloader
オブジェクトを返す 入る 出ない 再実行 再実行
data(…, status 400) 入る 出ない なし なし
data(…, status 500) 入る 出ない なし なし
throw new Error() null 出る 再実行 なし
redirect(“/list”) null 出ない 再実行 移動先を実行
fetcher経由で成功 fetcher.dataへ 出ない 再実行 再実行

見落としやすいのは500の行です。data() で500を返してもErrorBoundaryは出ず、actionDataに入るだけです。エラー画面に切り替えたいなら throw します。親ルートの shouldRevalidate に渡る actionStatus は、400と500のときに数値が入り、プレーンなオブジェクトを返したときは undefined でした。

Data ModeとFramework ModeのGET遷移時の再検証

公式のRevalidation Optimizationによると、送信後の再検証の既定はどのモードでも同じです。違うのは <Link> によるGET遷移で、SSRありのFramework Modeでは表示中のloaderがすべて再実行されます。Data ModeとFramework ModeのSPAモードでは、パスのパラメータか検索パラメータが変わったルートだけが再実行されます。同じURLへのGET遷移は、ページの再読み込みと同じ扱いで全loaderが再実行されます。

shouldRevalidateによるloaderの再検証の除外

集計のような重いloaderを、関係のないactionの後まで再実行したくない場合は、そのルートに shouldRevalidate をexportします。常に false を返すとパラメータが変わっても再実行されなくなるので、条件に当てはまらないときは defaultShouldRevalidate を返します。

import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction?.startsWith("/favorites")) return false;
  return defaultShouldRevalidate;
}

送信1回だけを対象にするなら、<Form> や fetcher.submit に defaultShouldRevalidate={false} を渡す方法もあります。8.5.1では、fetcherのactionがredirectを返したのに後続の遷移が始まって追従しなかった場合も、loaderが再検証されるよう修正されました。

clientActionが優先されるルールと使いどころ

公式のActionsガイドによると、clientAction はブラウザだけで実行され、action と両方定義されていれば clientAction が優先されます。この順位を知らずに両方書くと、サーバ側の更新処理が呼ばれません。サーバ処理も通したい場合は、引数の serverAction を呼びます。

export async function clientAction({ serverAction }: Route.ClientActionArgs) {
  projectCache.clear();
  return serverAction();
}

公式のClient Dataガイドも、clientAction でブラウザ側のキャッシュを捨ててから serverAction() を呼ぶ形を示しています。clientAction が向くのは、localStorageやIndexedDBの更新、ブラウザにしか無いトークンで叩く外部API、サーバ処理が無いSPAモードです。決済・在庫の引き当て・権限変更を clientAction だけで完結させるのは設計ミスです。検証も更新もブラウザ側にあるので、DevToolsから任意のリクエストを送られると素通りします。

Form・useSubmit・useFetcherの使い分けと送信中の表示

画面遷移の有無による呼び出し方の選択

判断軸は「送信後に画面遷移させたいか」です。<Form> と useSubmit はナビゲーションを起こします。8.5.1で確かめると、別のURLへのPOSTは履歴に1件追加(PUSH)、表示中と同じURLへのPOSTは置き換え(REPLACE)でした。useFetcher の送信はナビゲーションを起こしません。ただしactionが redirect() を返すと、fetcherからでも移動先へ遷移し、履歴に追加されます。一覧の行ごとの「完了にする」ボタンやいいねボタンは useFetcher、送信後に完了画面へ進むフォームは <Form> です。入力・確認・送信の多段フォームは、Reactで確認画面付きフォームを作る方法の設計がそのまま応用できます。

useNavigationとfetcher.stateによる送信中の表示

<Form> の送信中は useNavigation() の state が "submitting" になり、formAction に送信先が入ります。fetcherは独立した状態を持つので、fetcher.state !== "idle" で判定します。連打による二重送信は、この判定でボタンを disabled にすれば画面側で抑えられます。ただし画面の制御はサーバでの重複処理までは防がないので、注文のように重複が困る処理はサーバ側でも冪等キーなどで弾きます。

const navigation = useNavigation();
const busy = navigation.state === "submitting" && navigation.formAction === "/projects/new";
<button type="submit" disabled={busy}>{busy ? "送信中…" : "作成"}</button>

fetcher.formDataで作る楽観的UI

サーバの応答を待たずに成功後の表示を先に出すときは、送信中の値が入っている fetcher.formData を使います。専用のライブラリは要りません。

function Task({ task }) {
  const fetcher = useFetcher();
  let isComplete = task.status === "complete";
  if (fetcher.formData) {
    isComplete = fetcher.formData.get("status") === "complete";
  }
  return (
    <fetcher.Form method="post">
      <button name="status" value={isComplete ? "incomplete" : "complete"}>
        {isComplete ? "未完了に戻す" : "完了にする"}
      </button>
    </fetcher.Form>
  );
}

送信が終わると fetcher.formData は空になり、表示はloaderのデータに戻ります。actionが400を返した場合は再検証されないため、元の値に戻ります。ロールバックを自分で書く必要はありません。

loaderとactionが使えるモードとData Modeの書き方

React Routerには3つのモードがあり、データAPIが使えるかはモードで決まります。Declarative Modeでは loader も action も使えません。「react router v7 loader」で調べて情報が食い違うのは、記事ごとに前提のモードが違うためです。

モード ルート定義 loader / action clientLoader / clientAction SSR
Declarative JSX(BrowserRouterなど) 不可 不可 手動で構成
Data createBrowserRouter 可(実行場所は構成次第) 不可 手動で構成
Framework routes.ts+Viteプラグイン 可(サーバで実行) 可 組み込み

Data Modeではルートオブジェクトに loader と action を書きます。次の例は createBrowserRouter でブラウザだけに描画する構成なので、どちらもブラウザで動きます。Data Modeでも createStaticHandler と createStaticRouter を使えばSSRを自分で組めます。値は useLoaderData と useActionData で受け取ります。

import { createBrowserRouter, data, redirect, useActionData, Form } from "react-router";
import { RouterProvider } from "react-router/dom";

const router = createBrowserRouter([
  {
    path: "/projects/new",
    action: async ({ request }) => {
      const title = String((await request.formData()).get("title") ?? "");
      if (!title) return data({ error: "必須です" }, { status: 400 });
      await api.createProject({ title });
      return redirect("/projects");
    },
    Component: NewProject,
  },
  { path: "/projects", loader: () => api.listProjects(), Component: ProjectList },
]);

function NewProject() {
  const result = useActionData();
  return (
    <Form method="post">
      <input name="title" />
      {result?.error ? <em>{result.error}</em> : null}
      <button>作成</button>
    </Form>
  );
}

// 描画は <RouterProvider router={router} />

Framework Modeでは、ルートごとの型がtypegenで生成されます。import type { Route } from "./+types/project"; の1行で Route.ActionArgs や Route.ComponentProps が使え、params の名前の誤りはコンパイル時に見つかります。ルートを変えた直後の型エラーは、開発サーバの起動か react-router typegen の実行で消えます。ルーター自体の選定で迷っているなら、TanStack Routerとは?型安全ルーティングの特徴・導入手順とReact Routerとの違いも判断材料になります。

React Router v8でloader・actionまわりに入った変更

npmの latest は8.5.1(2026年10月27日公開)で、8.0.0は2026年6月17日に出ました。7系も7.18.5(2026年10月27日)まで並行して更新が続いています。v8の最低要件はNode 22.22以上、React 19.2.7以上で、Framework ModeはVite 7以上です。公式のFuture Flagsガイドによると、メジャー版はおおむね年1回で、v9はNode 22のサポートが終わる2027年半ばの見込みです。

middlewareが既定になりcontextはRouterContextProviderに固定

v7で future.v8_middleware の裏にあったmiddlewareは、v8で常に有効になりました。loader・action・middlewareが受け取る context は必ず RouterContextProvider のインスタンスで、素のオブジェクトは渡せません。

import { createContext } from "react-router";

export const userContext = createContext<User | null>(null);

export async function loader({ context }: Route.LoaderArgs) {
  const user = context.get(userContext);
  return { profile: await getProfile(user) };
}

カスタムサーバでは、getLoadContext が new RouterContextProvider() を返し、context.set() で値を入れる形に書き換えます。

request.urlの正規化廃止と新しいurl引数

v8では、受け取った request がそのまま渡ります。v7まで request.url から取り除かれていた .data サフィックスやindex用の検索パラメータが、v8では残ります。正規化済みのURLは、新しく加わった url 引数から取ります。

// v8
export async function loader({ request, url }: Route.LoaderArgs) {
  if (url.pathname === "/path") { }
  const isDataRequest = new URL(request.url).pathname.endsWith(".data");
}

データリクエストのURLも、末尾スラッシュを区別するため /a/b/c.data から /a/b/c/_.data に変わりました。CDNやリバースプロキシで .data のパスに条件を書いている設定は直す必要があります。react-router-dom の削除と、metaの引数 data から loaderData への改名もv8の変更です。

8.3.1・7.18.3でのactionの送信元チェックの厳格化

React Routerは7.12.0(2026年1月7日)から、UIルートのactionに届いた Origin ヘッダが request.url と一致しなければ処理を中止しています(CSRF対策)。8.3.0までは比較がホスト(ホスト名とポート)だけでしたが、8.3.1と7.18.3(どちらも2026年8月28日)からはスキームまで含めた比較になりました。createRequestHandler に同じPOSTを送って比べた結果が次の表です。

Origin → request.url 8.3.0 8.5.1
https://app.example.com → https://app.example.com 200 200
https://app.example.com → http://app.example.com 200 400
https://app.example.com → http://localhost:3000 400 400
Originヘッダなし 200 200

2行目は、ロードバランサーやnginxでTLSを終端し、アプリにはhttpで渡している構成です。Expressで trust proxy を有効にしていないと、request.url は http:// で組み立てられます。8.3.1以降へ上げた途端にフォーム送信だけが400になったら、まずここを疑ってください。対処は、プロキシからの X-Forwarded-Proto と X-Forwarded-Host を信頼する設定にするか、react-router.config.ts の allowedActionOrigins に公開ホストを書くかです。

// react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  allowedActionOrigins: ["app.example.com", "*.example.com"],
} satisfies Config;

Express側で直すなら、trust proxy にプロキシの段数かアドレスを指定します(例:プロキシが1段なら app.set("trust proxy", 1))。true にするとクライアントが送った転送ヘッダまで信用するので、プロキシ側で X-Forwarded-* を必ず上書きしている構成に限ります。なお、この送信元チェックはUIルートのactionだけが対象で、リソースルートへのPOSTには掛かりません。

v8へ今すぐ上げるべきでない場面

Node 22.22またはReact 19.2.7へ上げられない環境は、選択の余地なく見送りです。CommonJSでビルドしている構成も、v8がESM専用で公開されているため、バンドラ設定から作り直すことになります。Cloudflare向けに @react-router/dev/vite/cloudflare のdev proxyを使っている場合は、それが削除されたので @cloudflare/vite-plugin への置き換えが要ります。どれにも当たらないなら、7.18.5でfutureフラグを1つずつ有効にして動作を確かめ、8.5.1へ上げるのが早道です。Reactの知見を持つ開発体制が社内に無い場合は、業務用・Webアプリ開発のように移行ごと外部に任せる選択肢もあります。

よくある質問

loaderとclientLoaderはどちらを使うべきですか?

SSRかプリレンダリングで初回表示を速くしたい、またはDBやシークレットを直接扱いたいなら loader です。SPAモードで運用している、またはブラウザにしか無い認証情報で外部APIを叩くなら clientLoader を選び、HydrateFallback もexportしてください。両方定義すると、初回は loader、以降のクライアント遷移は clientLoader が動きます。

actionを呼んだあと、一覧を再取得するコードは必要ですか?

actionが2xxで終われば不要です。<Form>・useSubmit・useFetcher のどれから呼んでも、画面上のloaderが自動で再実行されます。data() で400や500を返したときは再実行されません。エラーでも一覧を取り直したいなら、ステータスはそのままにして、そのルートの shouldRevalidate で actionStatus を見て true を返します。

actionとclientActionを両方定義したらどちらが動きますか?

clientAction が動き、action は自動では呼ばれません。サーバ側も通したいときは、clientAction の中で引数の serverAction() を呼びます。

React 19のform actionやServer ActionsとReact Routerのactionは同じものですか?

別物です。React 19の <form action={fn}> は、送信時に FormData を1つ受け取る関数をコンポーネント側で渡す仕組みです。React Routerの action はルート単位でexportし、request を受け取ってURLに紐づけて呼ばれます。完了後のloader再検証もReact Routerの action だけの動きです。React 19側の書き方はReact 19のform actionの使い方|引数はFormData・よくある誤りで扱っています。

Declarative Modeでもloaderは使えますか?

使えません。<BrowserRouter> と <Routes> で組んだアプリでloaderやactionを使うには、createBrowserRouter のData Modeへ移るか、Framework Modeを採用します。JavaScriptが無くてもフォームが動く設計にしたい場合は、PESPAとは?SPA・MPAとの違いとReact Router v8・Remix 3での実装が参考になります。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.27 テックブログ Brotli 脆弱性:CVE-2026-111497の影響確認とNode.js・Python・.NETの更新手順
  2. 2026.10.28 テックブログ リンツ不正アクセス:314万件の対象確認とパスワード強制リセット後の対処
  3. 2026.10.27 テックブログ Joshin(ジョーシン)不正アクセス:最大600万件の対象とサブスク初期パスワードの対処
  4. 2026.10.28 テックブログ 楽天トラベル不正アクセス:最大約260万件の対象確認とカード下4桁の詐欺対策
  5. 2026.10.23 テックブログ Renta!はいつ再開?不正アクセスでメールアドレス等が流出したおそれ・再開時のパスワード再設定と運営側の対応

RELATED POSTS 関連記事

目次