React Routerのアクションで、フォームの送信を受け取る方法
React Router のデータモードでは、ルートに action を書き、React Router の Form で送信すると、その関数が入力を受け取ります。入力の誤りを返して画面に出すことも、保存したあとに別のページへ移ることも、アクションの戻り値で決められます。保存のあとは、表示中のページのローダーが自動で呼び直されるので、一覧を読み直す処理を自分で書く必要もありません。
筆者は検証環境で、猫の一覧と猫の登録フォームを作り、アクションに入力が届くまでの流れと、ローダーが呼び直される時刻を記録して確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 141 です。使ったのは react-router 8.5.0、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2 です。データはサーバーの代わりに、決まった時間(読み込みは300ミリ秒、保存は500ミリ秒)だけ待ってから返す関数で用意しました。ブラウザでの確認は、esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright で操作して行いました。
記事のコードは、検証に使ったコードから時刻を記録する部分を省き、節ごとに説明に要るところだけを載せたものです。
ルートに action を書き、Form で送信する
アクションは、ルートの定義に action として書きます。データモードのアクションのガイドにあるとおり、Form に method="post" を付けて送信すると、アクションが呼ばれます。検証では、一覧のルート / の子に、登録のページ /new を作りました。
import { createRoot } from 'react-dom/client';
import { createBrowserRouter } from 'react-router';
import { RouterProvider } from 'react-router/dom';
const router = createBrowserRouter([
{
path: '/',
loader: rootLoader,
Component: Root,
HydrateFallback: Loading,
children: [
{ index: true, Component: Home },
{ path: 'new', action: newCatAction, Component: NewCat },
],
},
]);
createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />);
登録のページのフォームは、react-router の Form で書きます。HTML の <form> と同じように、入力欄には name を付けます。
import { Form } from 'react-router';
export function NewCat() {
return (
<Form method="post">
<label>
名前 <input name="name" />
</label>
<label>
毛の色
<select name="color">
<option>三毛</option>
<option>黒</option>
<option>白</option>
</select>
</label>
<button type="submit">登録する</button>
</Form>
);
}
Form に action を指定していないので、送信先は、このフォームを描画しているルート(/new)になります。Form のリファレンスにも、action を省くと、いちばん近いルートに送ると書かれています。
アクションは request.formData() で入力を受け取る
アクションは引数で request を受け取ります。await request.formData() とすると、フォームの入力を FormData として取り出せます。値は、入力欄の name を使って formData.get('name') のように読みます。
import { type ActionFunctionArgs } from 'react-router';
export async function newCatAction({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const name = String(formData.get('name') ?? '').trim();
const color = String(formData.get('color') ?? '');
// ここで入力を確かめて、保存する(次の節で書き足します)
}
検証環境で、名前に「しろ」を入れ、毛の色に「白」を選んで送信すると、アクションには次の値が届きました。request.method は POST です。
440ms newCatAction: 開始(method=POST)
444ms newCatAction: formData = {"name":"しろ","color":"白"}
このとき、ブラウザからサーバーへ POST のリクエストは送られていませんでした。検証では、ページを返す仕組みに届いたリクエストを記録していて、記録に残ったのは最初にページを開いたときの GET /new だけです。createBrowserRouter で作ったルーターでは、アクションはブラウザの中で動く関数です。データをサーバーに保存したいときは、アクションの中で自分でサーバーに送る処理を書くことになります。この記事の検証では、サーバーの代わりに、500ミリ秒待ってから配列に追加する関数を呼んでいて、実際のサーバーへの送信は試していません。
入力の誤りは 400 で返し、useActionData で表示する
入力に誤りがあるときは、アクションから誤りの内容を返します。フォームの入力チェックのガイドと同じく、data() でステータスコード 400 を付けて返しました。
import { data, redirect, type ActionFunctionArgs } from 'react-router';
export async function newCatAction({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const name = String(formData.get('name') ?? '').trim();
const color = String(formData.get('color') ?? '');
const errors: { name?: string } = {};
if (name === '') errors.name = '名前を入れてください';
else if (name.length > 10) errors.name = '名前は10文字までです';
if (Object.keys(errors).length > 0) {
return data({ errors }, { status: 400 });
}
await addCat(name, color);
return redirect('/');
}
返した値は、コンポーネントで useActionData を使って受け取ります。useActionData のリファレンスによると、まだ一度もアクションが呼ばれていないときの値は undefined です。
import { Form, useActionData } from 'react-router';
export function NewCat() {
const actionData = useActionData<typeof newCatAction>();
return (
<Form method="post">
<label>
名前 <input name="name" />
</label>
{actionData?.errors.name && <p role="alert">{actionData.errors.name}</p>}
{/* 毛の色と送信ボタンは前と同じ */}
</Form>
);
}
useActionData<typeof newCatAction>() と書くと、戻り値に型が付きます。検証で、?. を付けずに actionData.errors.name と書くと、型チェックで次のエラーになりました。送信する前は undefined なので、確かめてから読む必要があります。
errors/NoCheck.tsx(6,14): error TS18048: 'actionData' is possibly 'undefined'.
名前を空のまま送信すると、URL は /new のまま、名前の欄の下に「名前を入れてください」と出ました。続けて11文字の名前を入れ、毛の色を「白」にして送信すると、「名前は10文字までです」に変わりました。入れた名前と選んだ毛の色は、送信したあとも欄に残っていました。

400 を返したとき、一覧のローダー(rootLoader)は呼び直されませんでした。記録では、送信してから画面を描き直すまでの間に、ローダーの開始が出ていません。
412ms router: navigation.state=submitting location=/new
412ms newCatAction: 開始(method=POST)
415ms newCatAction: formData = {"name":"","color":"三毛"}
415ms newCatAction: 入力に誤りがあるので 400 で返す(名前を入れてください)
416ms router: navigation.state=idle location=/new
417ms NewCat を描画(navigation.state = idle、actionData = {"errors":{"name":"名前を入れてください"}})
入力チェックのガイドにも、React Router では 2xx のステータスコードのときだけページのデータを読み直すので、400 を返すと読み直しが起きない、と書かれています。何も保存していないときに、一覧を読み直す通信をしなくて済みます。
保存できたら redirect で移動し、一覧のローダーが呼び直される
入力に誤りがなければ、addCat で保存してから redirect('/') を返します。redirect のリファレンスによると、これは移動先(Location)を持ったレスポンスです。検証でも、redirect('/') を返すと / へ移動しました。
検証で「しろ」を登録したときの記録です。
440ms router: navigation.state=submitting location=/new
440ms newCatAction: 開始(method=POST)
444ms newCatAction: formData = {"name":"しろ","color":"白"}
445ms NewCat を描画(navigation.state = submitting、actionData = null)
945ms addCat: しろ(白)を保存した
945ms newCatAction: 一覧へリダイレクトする
947ms router: navigation.state=loading location=/new
947ms rootLoader: 開始
951ms NewCat を描画(navigation.state = loading、actionData = null)
1247ms rootLoader: 終了(3匹)
1249ms router: navigation.state=idle location=/
アクションが終わると、すぐに一覧のローダーが呼び直されました。ローダーが3匹のデータを返したところで URL が / になり、一覧には「しろ(白)」が加わっていました。一覧を読み直すコードは書いていません。ルートオブジェクトの説明にも、ローダーが呼び直される場合の1つに、アクションが呼ばれて、エラーではないステータスコードを返したときが挙げられています。

送信してから一覧が出るまでに、navigation.state は submitting、loading、idle の順に変わりました。アクションが動いているあいだが submitting、そのあとローダーを読み直しているあいだが loading です。
送信中は useNavigation でボタンを止める
保存に時間がかかると、ボタンを押しても何も変わらないように見えます。読み込み中の表示(Pending UI)についてのガイドでは、Form で送信したときの状態は useNavigation で調べられると説明されています(データモードでも同じだと、データモード向けのページに書かれています)。検証では、navigation.state が submitting のあいだ、ボタンを押せないようにし、文言を変えました。
import { Form, useActionData, useNavigation } from 'react-router';
export function NewCat() {
const actionData = useActionData<typeof newCatAction>();
const navigation = useNavigation();
const submitting = navigation.state === 'submitting';
return (
<Form method="post">
{/* 名前の欄、誤りの表示、毛の色の欄は前と同じ */}
<button type="submit" disabled={submitting}>
{submitting ? '登録中…' : '登録する'}
</button>
</Form>
);
}
「登録する」を押してから約200ミリ秒後の画面では、ボタンが「登録中…」になっていました。URL はまだ /new です。

この書き方では、ローダーを読み直している loading のあいだは、ボタンが「登録する」に戻ります。検証の記録でも、アクションが終わってから一覧が出るまでの約300ミリ秒は loading で、そのとき NewCat は loading の状態で描き直されていました。この状態では、submitting かどうかの判定は false です。idle になるのは一覧が出たときなので、それまでボタンを止めておきたいときは、navigation.state !== 'idle' で判定する方法もあります(この判定は試していません)。
アクションに受け取りを任せ、結果は戻り値で返す
React Router のデータモードでは、ルートに action を書き、Form method="post" で送信すると、アクションが request.formData() で入力を受け取ります。検証では、入力の誤りを data() で 400 を付けて返すと、useActionData で画面に出せ、一覧のローダーは呼び直されませんでした。保存したあとに redirect を返すと、一覧のローダーが自動で呼び直され、新しい猫が加わった一覧が表示されました。createBrowserRouter のアクションはブラウザの中で動く関数なので、サーバーに保存したいときは、データをサーバーへ送る処理をアクションの中に書きます。送信中の表示は、useNavigation の state で切り替えられます。誤りを見せるのも、保存して別のページへ移るのも、アクションが何を返すかで決まります。





