React Routerのアクションで、フォームの送信を受け取る方法

​​React​React Router,TypeScript

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 を作りました。

tsx
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 を付けます。

tsx
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') のように読みます。

tsx
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 です。

text
  440ms newCatAction: 開始(method=POST)
  444ms newCatAction: formData = {"name":"しろ","color":"白"}

このとき、ブラウザからサーバーへ POST のリクエストは送られていませんでした。検証では、ページを返す仕組みに届いたリクエストを記録していて、記録に残ったのは最初にページを開いたときの GET /new だけです。createBrowserRouter で作ったルーターでは、アクションはブラウザの中で動く関数です。データをサーバーに保存したいときは、アクションの中で自分でサーバーに送る処理を書くことになります。この記事の検証では、サーバーの代わりに、500ミリ秒待ってから配列に追加する関数を呼んでいて、実際のサーバーへの送信は試していません。

入力の誤りは 400 で返し、useActionData で表示する

入力に誤りがあるときは、アクションから誤りの内容を返します。フォームの入力チェックのガイドと同じく、data() でステータスコード 400 を付けて返しました。

tsx
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 です。

tsx
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 なので、確かめてから読む必要があります。

text
errors/NoCheck.tsx(6,14): error TS18048: 'actionData' is possibly 'undefined'.

名前を空のまま送信すると、URL は /new のまま、名前の欄の下に「名前を入れてください」と出ました。続けて11文字の名前を入れ、毛の色を「白」にして送信すると、「名前は10文字までです」に変わりました。入れた名前と選んだ毛の色は、送信したあとも欄に残っていました。

検証環境のChromiumで、猫の一覧(たま、くろ)の下に登録フォームがあり、名前の欄に「ながいなまえのねこさん」、その下にピンクの文字「名前は10文字までです」、毛の色は「白」が選ばれている画面
11文字の名前を送信した後の画面。誤りが表示され、入力は欄に残っている

400 を返したとき、一覧のローダー(rootLoader)は呼び直されませんでした。記録では、送信してから画面を描き直すまでの間に、ローダーの開始が出ていません。

text
  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('/') を返すと / へ移動しました。

検証で「しろ」を登録したときの記録です。

text
  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つに、アクションが呼ばれて、エラーではないステータスコードを返したときが挙げられています。

横軸が0〜1000ミリ秒の図。上の段は navigation.state で、0〜約500msが水色の「submitting」、そこから約800msまでが薄い水色の「loading」。その下に newCatAction(保存)505ms と rootLoader(一覧)300ms の水色の棒が順に並び、809msの位置にピンクの縦線「一覧を表示(809ms)」がある
送信が始まってから一覧が表示されるまで(棒の数字はかかった時間)

送信してから一覧が出るまでに、navigation.state は submitting、loading、idle の順に変わりました。アクションが動いているあいだが submitting、そのあとローダーを読み直しているあいだが loading です。

送信中は useNavigation でボタンを止める

保存に時間がかかると、ボタンを押しても何も変わらないように見えます。読み込み中の表示(Pending UI)についてのガイドでは、Form で送信したときの状態は useNavigation で調べられると説明されています(データモードでも同じだと、データモード向けのページに書かれています)。検証では、navigation.state が submitting のあいだ、ボタンを押せないようにし、文言を変えました。

tsx
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 です。

検証環境のChromiumで、猫の一覧(たま、くろ)の下の登録フォームに名前「しろ」、毛の色「白」が入り、ボタンがピンクの「登録中…」になっている画面
「登録する」を押してから約200ミリ秒後。アクションの実行中で、ボタンが「登録中…」になっている
ポイント

この書き方では、ローダーを読み直している 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 で切り替えられます。誤りを見せるのも、保存して別のページへ移るのも、アクションが何を返すかで決まります。

​Posted by 86