React

React 19のform actionの使い方|引数はFormData・よくある誤り

React 19では<form>actionプロパティに関数を渡せるようになり、送信処理を非同期関数として書けるようになりました。ところが、この関数はonSubmitのハンドラとは受け取る引数が違います。ここを取り違えたコードがそのまま動かない、というつまずきが多い機能です。この記事では、公式仕様で確定している挙動、useFormStatusとuseActionStateの役割分担、そしてonSubmitを残したほうがよい場面を、動くコードで整理します。非同期処理そのものの考え方はAsync Reactの技術的背景もあわせて参照してください。

まとめ

  • actionに渡した関数の引数はイベントではなくFormDataevent.preventDefault()は書かない(書くと実行時エラー)
  • 送信中の表示はuseFormStatuspending、処理結果とエラーはuseActionStatestateisPendingはuseActionState側にしか無い
  • 関数を渡すとmethodの指定に関わらずPOSTとして扱われ、成功時に非制御フィールドだけが自動リセットされる
  • 制御コンポーネント中心のフォームや、バリデーション基盤を導入済みのプロジェクトでは、onSubmitを残す判断が妥当な場面がある

以降は、この4点をreact.devの記述と動くコードで確認します。

form actionに関数を渡したときの挙動

actionプロパティは、文字列を渡すか関数を渡すかで意味が変わります。文字列を渡した場合は標準のHTMLフォームと同じく送信先URLとして扱われ、関数を渡した場合だけReactのAction(トランジション内で実行される送信処理)になります。同じプロパティ名で挙動が切り替わる点が、この機能を分かりにくくしている原因です。

引数はイベントではなくFormData

React DOMのformリファレンスは、関数を渡した場合について「送信されたフォームのFormDataを含む単一の引数で呼ばれる」と明記しています。onSubmitのハンドラのようにSubmitEventが渡されるわけではありません。したがってevent.preventDefault()new FormData(event.target)も不要で、書けばそこで落ちます。

// 誤り: action に渡した関数はイベントを受け取らない
async function submitContactNG(event) {
  event.preventDefault();                   // TypeError: event.preventDefault is not a function
  const data = new FormData(event.target);  // event.target は存在しない
}

// 正しい: 第1引数がそのまま FormData
async function submitContactOK(formData) {
  const email = formData.get("email");
}

入力値はname属性をキーにしてformData.get("email")で取り出します。getの戻り値は文字列またはFileのため、そのまま文字列として扱う前に型を確認しておくと後述のTypeScript対応が楽になります。

methodプロパティの扱いと非制御フィールドの自動リセット

関数を渡したフォームには、覚えておくべき挙動が2つあります。ひとつは、methodプロパティに何を指定してもHTTPメソッドがPOSTとして扱われる点。ブラウザによるネイティブ送信そのものは起きず、実際の通信は関数内のfetchが担うので、GETのリクエストを投げること自体は自由です。影響が出るのはuseFormStatusが返すmethodの値と、JavaScriptの読み込み前に送信された場合のネイティブ送信に頼る構成です。

もうひとつは、Actionが成功した後、フォーム内の非制御フィールドがReactによって自動的にリセットされること。対象は非制御フィールドだけで、値をstateで持つ制御コンポーネントは影響を受けません。リセットのタイミングを自分で決めたい場合は、React 19で追加されたrequestFormResetを使います。非制御フィールドの値をrefで読む書き方はuseRefによるDOM参照の整理もあわせて確認してください。

非同期送信フォームの基本実装

最小構成は、非同期関数をそのままactionへ渡すだけです。React側で送信がトランジションとして扱われるため、送信中フラグを自前のuseStateで持つ必要はありません。

function ContactForm() {
  async function submitContact(formData) {
    const res = await fetch("/api/contact", {
      method: "POST",
      body: formData,
    });
    if (!res.ok) {
      throw new Error("送信に失敗しました");
    }
  }

  return (
    <form action={submitContact}>
      <input type="text" name="name" required />
      <input type="email" name="email" required />
      <button type="submit">送信</button>
    </form>
  );
}

FormDataをそのままfetchのbodyへ渡せるので、送信前にオブジェクトへ詰め替える処理も消えます。関数内で例外が投げられた場合の行き先は、最も近いエラーバウンダリです。画面上にエラーメッセージを出したいのであれば、例外ではなく戻り値で結果を返す次の書き方に切り替えます。

送信中・結果・エラーの状態管理

状態管理のフックは2つあり、見る対象が違います。useFormStatusは親フォームの送信中かどうかだけを見るUI向けのフック、useActionStateはAction本体をラップして戻り値を状態として保持するフックです。ボタンの無効化は前者、エラーメッセージの表示は後者、と分けて考えると迷いません。送信結果を待たずに画面へ反映する楽観的更新が必要ならuseOptimisticが加わりますが、これは表示の先出しが要る場面だけの選択肢で、フォームのActionを使うために必須ではありません。

useFormStatusによる送信中のボタン制御

useFormStatusが返すのはpending(boolean)、data(FormDataまたはnull)、method(getまたはpost)、action(親フォームに渡された関数またはnull)の4つです。dataは送信中のFormDataなので、data.get("email")で送信内容をそのまま確認画面に出せます。actionがnullになるのは、親フォームが無い場合、actionプロパティを指定していない場合、そしてactionにURL文字列を渡した場合です。最大の注意点は、親の<form>しか見ないこと。<form>を返しているコンポーネント自身で呼んでも、その<form>の状態は取得できません。送信ボタンを子コンポーネントに切り出すのが定石です。

import { useFormStatus } from "react-dom";

async function submitContact(formData) {
  await fetch("/api/contact", { method: "POST", body: formData });
}

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "送信中" : "送信"}
    </button>
  );
}

function ContactForm() {
  return (
    <form action={submitContact}>
      <input type="email" name="email" required />
      <SubmitButton />
    </form>
  );
}

インポート元がreactではなくreact-domである点も間違えやすいところです。

useActionStateによる結果とエラーの受け取り

useActionState[state, formAction, isPending]を返します。第1引数のAction関数が受け取るのは(前回のstate, formData)の2つで、その戻り値が次のstateです。第2引数が初期state、第3引数は任意のpermalinkで、React Server Componentsでの段階的な強化(JavaScriptの読み込み前に送信された場合の遷移先)に使います。

import { useActionState } from "react";

async function submitContact(prevState, formData) {
  const res = await fetch("/api/contact", { method: "POST", body: formData });
  if (!res.ok) {
    return { message: "送信に失敗しました。時間をおいて再度お試しください。" };
  }
  return { message: "送信が完了しました。" };
}

function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitContact, { message: "" });

  return (
    <form action={formAction}>
      <input type="email" name="email" required />
      <button type="submit" disabled={isPending}>送信</button>
      <p>{state.message}</p>
    </form>
  );
}

フォームに渡すのは元のAction関数ではなく、返ってきたformActionです。Action関数が例外を投げるとキュー内の後続Actionはキャンセルされエラーバウンダリへ飛ぶため、想定内の失敗はthrowせず戻り値で返す設計にします。

pendingとisPendingの取り違え

送信中フラグの名前がフックによって違います。useFormStatuspendinguseActionStateは配列の3番目としてisPendingを返します。const { isPending } = useFormStatus()と書いてもエラーにはならず、値がundefinedになるだけです。ボタンのdisabledが常に偽になり、二重送信が素通りします。型チェックが効かないJavaScriptのコードでは気づきにくいため、既存コードの移植時はここを最初に確認してください。

actionとonSubmitの使い分け

React 19が出たからといって、既存のonSubmitをすべて置き換える必要はありません。両者は守備範囲が違います。

観点 action に関数 onSubmit
引数 FormData SubmitEvent
preventDefault 不要 必要
送信中の状態 useFormStatus / useActionState 自前のuseState
成功後の入力欄 非制御は自動リセット 手動でリセット
HTTPメソッド POST扱い(ネイティブ送信時) 制限なし

判断の分かれ目になりやすいのは、表の「引数」と「成功後の入力欄」の2行です。以下、移行して効果が出るケースと、残したほうがよいケースを分けます。

actionへ移行して効果が出るフォーム

入力欄を非制御のまま扱っている問い合わせフォーム、検索条件の送信、コメント投稿のように「入力値を送って結果を受け取るだけ」のフォームは移行の効果が大きい対象です。useStateによる入力値の保持、送信中フラグ、送信後のリセット処理がまとめて不要になり、コンポーネントから状態が消えます。ファイルアップロードを含むフォームも、FormDataがそのまま手に入るぶん有利です。

onSubmitを残すべきフォーム

移行しないほうがよい条件ははっきりしています。入力のたびに他の項目を書き換える、入力途中で候補を出す、といった入力値を制御コンポーネントとして持つ必然性があるフォームです。この場合、値はどのみちuseState側にあるためFormDataの利点が出ず、自動リセットの対象にもなりません。検索条件をクエリ文字列としてURLへ反映し、ブラウザの履歴や共有可能なURLを残したいフォームも、送信をJavaScript側で完結させるActionとは相性が悪い部類です。送信前に複数項目をまたぐバリデーションを行い、エラー時に送信そのものを止めたい設計でも、onSubmitで止めるほうが素直に書けます。「新機能だから」を理由に移行すると、リセット処理を自前で書き直すだけの作業になります。

TypeScriptでの型定義

actionへ直接渡す関数は、FormDataを1つ受け取りvoidまたはPromise<void>を返す形になります。useActionStateを使う場合はAction関数側に型を書けば、stateisPendingは推論されるため、フック呼び出しに型引数を書き足す必要はありません。

import { useActionState } from "react";

type ContactState = { message: string };

async function submitContact(
  prevState: ContactState,
  formData: FormData
): Promise<ContactState> {
  const email = formData.get("email");
  if (typeof email !== "string" || email === "") {
    return { message: "メールアドレスを入力してください。" };
  }
  await fetch("/api/contact", { method: "POST", body: formData });
  return { message: "送信が完了しました。" };
}

function ContactForm() {
  // state は ContactState、isPending は boolean に推論される
  const [state, formAction, isPending] = useActionState(submitContact, {
    message: "",
  });

  return (
    <form action={formAction}>
      <input type="email" name="email" required />
      <button type="submit" disabled={isPending}>送信</button>
      <p>{state.message}</p>
    </form>
  );
}

formData.get()の戻り値はFormDataEntryValue | null、つまり文字列かFileかnullです。上の例のようにtypeofで絞り込んでから使うと、値が空のときの分岐とバリデーションを同じ場所で書けます。React 19における型定義まわりの変更点はchildrenとpropsの型定義の整理もあわせて確認しておくと移行時の手戻りが減ります。

React Hook Form導入済みプロジェクトでの判断

すでにReact Hook Formなどのフォームライブラリを入れている場合、actionは競合する機能ではなく、担当範囲が重なる部分があるだけです。ライブラリ側が強いのは入力単位のバリデーション、エラーメッセージの保持、条件付きの項目表示といった送信前の制御で、actionとuseActionStateが担うのは送信そのものと送信後の状態です。

判断の目安は、そのフォームにバリデーションの要件がどれだけあるかです。項目が数個で必須チェック程度なら、ライブラリを外してactionだけで組むと依存が1つ減ります。逆に項目間の相関チェックや動的な入力欄があるフォームは、ライブラリ側に送信までを任せたままにしたほうが安全です。両方を1つのフォームで混ぜる場合は、送信後に入力欄を空にする責任をどちらが持つかを先に決めてください。Reactの自動リセットとライブラリのリセットが二重に走ると、エラー表示だけが画面に残る状態になります。ライブラリ側の設計はReact Hook Formのバリデーション実装で整理しています。

よくある質問

React Routerの<Form>のactionと同じものですか?

別のものです。React Routerが提供する<Form>コンポーネントのactionは、送信先のルートを指すパスであり、そのルートに定義されたaction関数が実行されます。一方この記事で扱っているのはReact DOMの<form>要素そのもののプロパティで、渡すのは関数です。名前が同じで概念が違うため、React Routerを使っているプロジェクトでは、小文字の<form>とRouterの<Form>のどちらを書いているかを毎回確認してください。

React 18でもactionに関数を渡せますか?

渡せません。フォームのActionはReact 19で追加された機能で、安定版のリリースは2024年12月5日です。React 18のまま関数を渡すと、actionプロパティの値が不正であるという警告が出たうえで、属性自体がDOMに出力されません。結果としてフォームは送信先未指定の状態になり、送信時に現在のURLへネイティブ送信されてページが遷移します。2026年8月1日時点でnpmのreactパッケージのlatestは19.2.8なので、19系の最新へ上げれば利用できます。

useFormStateはもう使えませんか?

useActionStateは、Canary版でReactDOM.useFormStateという名前だったものを改称したものです。React 19では改称にあわせてuseFormStateが非推奨になりました。Canary版の情報を参照して書かれた記事やサンプルは古い名前のままのことがあるため、インポート元がreact-domuseFormStateになっていたら、reactからのuseActionStateへ読み替えてください。

Server Functions(use server)は必須ですか?

必須ではありません。actionに渡す関数はクライアント側の通常の非同期関数でよく、この記事のコード例もすべてクライアントだけで動きます。Server Functionsと組み合わせるとフォーム送信をサーバー側の関数に直結できますが、それはReact Server Componentsを使う構成での選択肢であり、フォームのActionを使うための前提条件ではありません。

送信ボタンごとに違う処理を実行できますか?

できます。<form>actionは、<button><input type="submit"><input type="image">formActionプロパティで上書き可能です。「投稿する」と「下書き保存」のように、同じ入力内容に対して行き先の違う処理を並べたい場合は、フォーム全体に既定のActionを置き、別処理のボタンにだけformActionを指定します。

関連記事

資料請求

RELATED POSTS 関連記事