React 19で、formのactionに関数を渡して送信する方法

​​React​TypeScript,useFormStatus,フォーム

React 19 では、<form> の action に関数を渡せます。その関数は、送信されたフォームの FormData を受け取って呼ばれます。<form> のリファレンスにある、onSubmit で送信を扱う書き方では、e.preventDefault() を呼び、new FormData(e.target) を作ります。action に関数を渡せば、どちらも必要ありません。送信中かどうかは、useFormStatus を使えば、フォームの中のボタンから props なしで読めます。

筆者は検証環境で、action に渡す関数を同期と非同期の両方で書き、useFormStatus を呼ぶ場所を変えて動かしました。その結果と、公式ドキュメントに書かれている注意点をまとめます。useActionState は使わず、action に渡す関数そのものと useFormStatus だけを扱います。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react / @types/react-dom 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で動かしました。GIF は、同じコンポーネントを Playwright 1.56.1 の Chromium で動かして撮ったものです。サーバーは検証用に作った偽物で、こちらが合図するまで返事をしません。

action に渡した関数は、FormData を受け取って呼ばれる

React 19 の発表記事の、<form> Actions についての説明によると、React 19 から、<form>、<input>、<button> の action と formAction に関数を渡せるようになりました。

まず、いちばん簡単な同期の関数で試しました。入力欄は2つあります。q は value を渡さない入力欄(制御されていない入力欄)で、tag は value と onChange を渡した入力欄(制御された入力欄)です。

tsx
import { useState } from 'react';

export function SyncAction() {
  const [controlled, setControlled] = useState('');
  const [last, setLast] = useState('(まだ)');

  function search(formData: FormData) {
    setLast(`${formData.get('q')} / ${formData.get('tag')}`);
  }

  return (
    <form action={search}>
      <input name="q" />
      <input name="tag" value={controlled} onChange={(e) => setControlled(e.target.value)} />
      <button type="submit">検索</button>
      <p id="last">{last}</p>
    </form>
  );
}

q に「react」、tag に「form」と入れて「検索」を押しました。出力の 受け取った値 は、search が FormData から読んだ値です。最後の行は、submit イベントの defaultPrevented と、押したあとのページの URL です(jsdom はもともとページを移動しないので、URL は参考です)。

text
$ node run.mjs SyncAction
  送信前: q="react" tag="form" ボタン=["検索"] | 受け取った値=(まだ) | サーバー=[]
  検索を押したあと: q="" tag="form" ボタン=["検索"] | 受け取った値=react / form | サーバー=[]
  submit イベント: defaultPrevented=true URL=about:blank

search は、2つの入力欄の値を FormData で受け取りました。e.preventDefault() は書いていませんが、defaultPrevented=true なので、submit イベントの既定の動作(ページの移動)は止められています。<form> のリファレンスの、action で送信を扱う説明にも、onSubmit と違って e.preventDefault() はいらない、と書かれています。

押したあと、q は空になり、tag は「form」のままでした。同じ説明には、action の関数が成功で終わると、フォームの中の制御されていない入力欄がリセットされる、とあります。制御された入力欄の値は state が持っているので、リセットされずに残ります。

非同期の関数にして、送信中を子のボタンで読む

action に渡す関数は、非同期にもできます。<form> のリファレンスの props の説明によると、渡した関数は Transition の中で動きます。

送信中かどうかを表示するのに、useState はいりません。react-dom の useFormStatus を、フォームの中に置いたコンポーネントで呼びます。検証では、送信ボタンを SubmitButton というコンポーネントにして、useFormStatus が返す4つの値を、ボタンと一緒に表示しました。

tsx
import { useFormStatus } from 'react-dom';

export function SubmitButton({ label = '保存' }: { label?: string }) {
  const { pending, data, method, action } = useFormStatus();
  return (
    <>
      <button type="submit" disabled={pending}>
        {pending ? '保存中…' : label}
      </button>
      <span className="status">
        pending={String(pending)} data={data ? `"${data.get('title')}"` : 'null'} method={String(method)} action=
        {typeof action === 'function' ? action.name : String(action)}
      </span>
    </>
  );
}

フォームの側は、非同期の関数を action に渡し、中に SubmitButton を置くだけです。saveTitle は、偽のサーバーに送る関数です。

tsx
import { saveTitle } from './server';
import { SubmitButton } from './SubmitButton';

export function Basic() {
  async function save(formData: FormData) {
    const title = String(formData.get('title') ?? '');
    await saveTitle(title);
  }

  return (
    <form action={save}>
      <input name="title" defaultValue="" />
      <SubmitButton />
    </form>
  );
}

入力欄に「猫の手」と入れて「保存」を押し、サーバーの返事の前とあとで画面を記録しました。

text
$ node run.mjs Basic
  送信前: title="" ボタン=["保存"] | pending=false data=null method=null action=null | サーバー=[]
  保存を押した直後: title="猫の手" ボタン=["保存中…"(押せない)] | pending=true data="猫の手" method=get action=save | サーバー=[受付:猫の手]
  返事のあと: title="" ボタン=["保存"] | pending=false data=null method=null action=null | サーバー=[受付:猫の手 → 保存:猫の手]

サーバーが返事をするまで、pending は true で、ボタンは「保存中…」になって押せませんでした。data には送信中の FormData が入っていて、入力した「猫の手」を読めました。action は、<form> に渡した save 関数そのものです。method は get でした。useFormStatus のリファレンスの、戻り値の説明によると、<form> の method は、指定しなければ GET です。

返事が来ると、pending は false に戻り、data・method・action は null になりました。入力欄は、送信が成功したので空になっています。

SubmitButton は、フォームの props を何も受け取っていません。発表記事の、useFormStatus についての説明では、useFormStatus は、親の <form> が Context の提供元であるかのように、その状態を読むと説明されています。デザインシステムのボタンのように、props を何段も渡さずにフォームの状態を知りたい部品のための Hook です。

TypeScript では、useFormStatus の戻り値の型は、pending が true か false かで分かれています(@types/react-dom 19.3.0)。送信中の action の型は string | ((formData: FormData) => void | Promise<void>) なので、action.name をそのまま読もうとすると、tsc がエラーにしました。上のコードで typeof action === 'function' で確かめているのは、そのためです。

form と同じコンポーネントで呼ぶと、pending は true にならない

useFormStatus を、<form> を返すコンポーネントそのものの中で呼ぶと、どうなるでしょうか。次のように書きました。

tsx
import { useFormStatus } from 'react-dom';
import { saveTitle } from './server';

export function SameComponent() {
  // form と同じコンポーネントで useFormStatus を呼ぶ
  const { pending } = useFormStatus();

  async function save(formData: FormData) {
    await saveTitle(String(formData.get('title') ?? ''));
  }

  return (
    <form action={save}>
      <input name="title" />
      <button type="submit" disabled={pending}>
        {pending ? '保存中…' : '保存'}
      </button>
    </form>
  );
}
text
$ node run.mjs SameComponent
  送信前: title="" ボタン=["保存"] | サーバー=[]
  保存を押した直後: title="猫の手" ボタン=["保存"] | サーバー=[受付:猫の手]
  返事のあと: title="" ボタン=["保存"] | サーバー=[受付:猫の手 → 保存:猫の手]

サーバーが受け付けて返事を待っている間も、ボタンは「保存」のままで、押せる状態でした。pending が true にならなかったということです。型チェックも通り、エラーや警告も出ませんでした。

注意

useFormStatus は、<form> と同じコンポーネントで呼ぶと、そのフォームの状態を返しません。型チェックも通り、警告も出ないので、送信中にボタンが押せたままになっていることに気づきにくくなります。

useFormStatus のリファレンスの注意事項によると、useFormStatus が返すのは親の <form> の状態だけです。同じコンポーネントや、その子のコンポーネントの中で描画した <form> の状態は返しません。リファレンスの、pending が true にならないときの対処にあるとおり、useFormStatus は、<form> の中に置いた別のコンポーネントで呼びます。

次の GIF は、2つの書き方を Chromium の画面に縦に並べた様子です。上の「① 子のボタンで useFormStatus を呼ぶ」は Basic(中に SubmitButton)、下の「② form と同じコンポーネントで呼ぶ」は SameComponent です。入力欄に1文字ずつ入れてから、いちばん上の「2つとも保存する」ボタンで、2つのフォームの保存ボタンを押します。これを「猫の手」と「肉球」で2回くり返しました。

画面の上部には「クリック 1回目/2回」のように何回目かが出て、その下の帯にはサーバーの状態が出ます。押した直後と、サーバーが返事をした直後は、値が変わった欄がピンクの枠と文字になります。①は、返事を待つ間ボタンが「保存中…」になり、下の行の pending が true になります。②のボタンは「保存」のままです。返事が来ると、①も②も入力欄が空になります。

2つのフォームを縦に並べ、「2つとも保存する」ボタンで2回送信したときの、ボタンと入力欄、useFormStatus の値の変化
GIFアニメーション:2つのフォームで2回保存したときの動き。返事を待つ間、①のボタンは「保存中…」になって押せなくなり、②のボタンは「保存」のまま。返事のあとは、どちらも入力欄が空になる

ボタンごとに別の関数を呼ぶ:formAction

1つのフォームに、別の処理をするボタンを置きたいときは、<button> の formAction に関数を渡します。<form> のリファレンスの props の説明によると、<form> の action は、<button>、<input type="submit">、<input type="image"> の formAction で上書きできます。

tsx
import { saveTitle } from './server';
import { SubmitButton } from './SubmitButton';

export function TwoButtons() {
  async function save(formData: FormData) {
    await saveTitle(String(formData.get('title') ?? ''));
  }
  async function saveDraft(formData: FormData) {
    await saveTitle(`下書き:${formData.get('title')}`);
  }

  return (
    <form action={save}>
      <input name="title" />
      <SubmitButton />
      <button type="submit" formAction={saveDraft}>下書き保存</button>
    </form>
  );
}

「下書き保存」を押してから、「保存」を押しました。

text
$ node run.mjs TwoButtons
  下書き保存を押した直後: title="猫の手" ボタン=["保存中…"(押せない) "下書き保存"] | pending=true data="猫の手" method=get action=saveDraft | サーバー=[受付:下書き:猫の手]
  返事のあと: title="" ボタン=["保存" "下書き保存"] | pending=false data=null method=null action=null | サーバー=[受付:下書き:猫の手 → 保存:下書き:猫の手]
  保存を押した直後: title="猫の手" ボタン=["保存中…"(押せない) "下書き保存"] | pending=true data="猫の手" method=get action=save | サーバー=[受付:下書き:猫の手 → 保存:下書き:猫の手 → 受付:猫の手]
  返事のあと: title="" ボタン=["保存" "下書き保存"] | pending=false data=null method=null action=null | サーバー=[受付:下書き:猫の手 → 保存:下書き:猫の手 → 受付:猫の手 → 保存:猫の手]

「下書き保存」を押すと、<form> の save ではなく、ボタンの saveDraft が呼ばれました。このとき、SubmitButton の useFormStatus は pending=true になり、action には saveDraft が入っていました。どちらのボタンで送っても、SubmitButton は送信中を表示できるということです。

一方、「下書き保存」ボタンは、送信中も押せるままでした。このボタンは useFormStatus を読んでいない、ただの <button> だからです。

関数を渡す form に method を書くと、警告が出る

action に関数を渡した <form> に method="get" を書くと、開発版の React が次の警告を出しました。

text
$ node run.mjs GetMethod
Cannot specify a encType or method for a form that specifies a function as the action. React provides those automatically. They will get overridden.
  送信前: title="" ボタン=["保存"] | pending=false data=null method=null action=null | サーバー=[]
  保存を押した直後: title="猫の手" ボタン=["保存中…"(押せない)] | pending=true data="猫の手" method=get action=save | サーバー=[受付:猫の手]
  返事のあと: title="" ボタン=["保存"] | pending=false data=null method=null action=null | サーバー=[受付:猫の手 → 保存:猫の手]

警告の内容は、「action に関数を渡したフォームには、encType や method を指定できない。React が自動で決め、上書きする」というものです。<form> のリファレンスの注意事項にも、action や formAction に関数を渡したときは、method の値にかかわらず HTTP のメソッドは POST になる、とあります。関数を渡すフォームには、method を書かずにおきます。

なお、警告は出ましたが、送信の動きと useFormStatus の値は、method を書かなかったとき(Basic)と同じでした。useFormStatus の method は、どちらも get です。検証では HTTP の送信そのものは起きていないため、リファレンスの「POST になる」がこの値とどう関わるのかは確かめていません。

action の中でエラーを投げると、Error Boundary の表示に切り替わる

<form> のリファレンスの、送信のエラーを扱う説明によると、action の関数がエラーを投げたときは、<form> を包んだ Error Boundary が、代わりに用意した表示(fallback)に切り替わります。

検証では、getDerivedStateFromError を持つクラスコンポーネントで <form> を包み、action の非同期の関数でエラーを投げました。

tsx
export function Throwing() {
  async function save(formData: FormData) {
    await Promise.resolve();
    throw new Error(`保存できません: ${formData.get('title')}`);
  }

  return (
    <ErrorBoundary>
      <form action={save}>
        <input name="title" />
        <button type="submit">保存</button>
      </form>
    </ErrorBoundary>
  );
}
text
$ node run.mjs Throwing
  [onCaughtError] 保存できません: 猫の手
  保存を押したあと: form なし  ボタン=[] | 送信に失敗しました(保存できません: 猫の手) | サーバー=[]

フォームは消え、ErrorBoundary の「送信に失敗しました」が表示されました。[onCaughtError] の行は、createRoot に渡した onCaughtError が呼ばれたことを示しています。createRoot のリファレンスの引数の説明によると、onCaughtError は、Error Boundary がエラーを受け止めたときに呼ばれます。ErrorBoundary は検証用に書いた短いクラスで、ここではコードを省いています。

フォームごと消えると、入力した内容も一緒に消えます。「文字数が多すぎる」のような想定済みのエラーを、フォームの横に出したい場合もあるでしょう。<form> のリファレンスの、JavaScript なしでエラーを表示する説明には、エラーを投げずに返し、useActionState の state として表示する例があります。

Server Functions と組み合わせる場合(未検証)

action には、'use server' を付けた Server Function も渡せます。<form> のリファレンスの、Server Function で送信を扱う説明によると、Server Function を渡すと、JavaScript が読み込まれる前や無効なときでもフォームを送信できます。この点は、検証環境に Server Function を動かす仕組みを用意していないため、試していません。

ポイント

<form action={関数}> の関数は、FormData を受け取って呼ばれます。e.preventDefault() はいらず、成功すると制御されていない入力欄が空になります。送信中かどうかは、<form> の中に置いた子のコンポーネントで useFormStatus を呼んで読みます。<form> と同じコンポーネントで呼ぶと、pending は true になりません。

​Posted by 86