React

React 19のuseActionStateでフォーム送信|pending・エラー時の入力保持・成功時のリセット

React 19のuseActionStateとuseFormStatusでフォーム送信を実装。引数の順番、送信中の無効化、失敗時に入力を残して成功時だけ消す方法を、Viteで動くコードと検証結果で解説します。

この記事の目次
  1. useActionStateの第1引数は前のstate、第2引数がFormData
  2. 失敗時に入力を残すフォームを、6ファイルで動かす
  3. package.json
  4. index.html
  5. main.jsx
  6. mock-api.js
  7. App.jsx
  8. style.css
  9. Actionの正常完了と、受付成功を分けて入力を消す
  10. useOptimisticとServer Functionは、必要な責務が増えたときに選ぶ

React 19のフォームでエラーを表示できたのに、入力していた文章が消えた。そんなときは、Actionが正常に完了したことと、問い合わせの受付が成功したことを分けて考えます。

useActionStateは送信処理の戻り値と待機状態を管理します。useFormStatusは親のformの送信状態を読むHookです。 ただし、エラーを表すオブジェクトをreturnしただけでは、Reactにとっては関数が正常に完了しています。入力を失敗時に残したい場合、どの条件で消すかを別に設計します。

この記事では、Vite上で動く問い合わせ文フォームを作り、送信中・受付エラー・通信失敗・成功を切り替えて確かめます。React / React DOM 19.3.0、Vite 7.3.6で2026年9月12日に動作確認しました。API部分は学習用のモックで、メール送信やデータ保存は行いません。

useActionStateの第1引数は前のstate、第2引数がFormData

useActionStateから受け取るのは、state・formAction・isPendingの3つです。formActionを<form action={formAction}>へ渡すと、送信時にActionとして実行されます。通常のonSubmitのように、イベントを受け取ってpreventDefaultする形とは異なります。

値 今回の使い方
state 受付結果のkindとmessageを表示する
formAction formのactionへ渡して送信を受ける
isPending 結果欄の待機メッセージとaria-busyを切り替える

渡す関数はasync (_previousState, formData) => { ... }です。第1引数は直前の戻り値であり、最初の呼び出しではinitialStateです。フォームの値は第2引数から読みます。useActionStateを使う前の関数をそのまま移し、最初の引数にformDataという名前を付けると、stateに対してgetを呼んでしまいます。引数と戻り値の契約はuseActionStateの公式リファレンスで確認できます。

一方、useFormStatusはreact-domから読み込み、formの内側で描画される子コンポーネントから呼びます。formを返すApp自身で呼んでも、そのformの状態は取得しません。この配置はuseFormStatusの注意点にも示されています。

スポンサーリンク

失敗時に入力を残すフォームを、6ファイルで動かす

空の作業フォルダに、以下の6ファイルを置きます。App.jsxがフォーム本体、mock-api.jsが送信結果を切り替える部分です。検証用に処理を800ミリ秒待たせています。

package.json

{
  "name": "react-action-state-form-example",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite --host 127.0.0.1 --port 5207 --strictPort",
    "build": "vite build"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "vite": "7.3.6",
    "@playwright/test": "1.61.1"
  }
}

index.html

<!doctype html>
<html lang="ja">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>送信状態を確かめるフォーム</title></head>
<body><div id="root"></div><script type="module" src="/main.jsx"></script></body>
</html>

main.jsx

import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './style.css';

createRoot(document.getElementById('root')).render(
  <StrictMode><App /></StrictMode>
);

mock-api.js

// 学習用。ネットワーク通信もメール送信も行いません。
export async function sendMessage(message) {
  await new Promise(resolve => setTimeout(resolve, 800));
  if (message === '通信失敗') throw new Error('demo transport error');
  if (message === '重複') {
    return { ok: false, message: '同じ内容を受付済みです。内容を確認してください。' };
  }
  return { ok: true, message: 'デモ受付が完了しました。' };
}

App.jsx

import React, { startTransition, useActionState, useState } from 'react';
import { useFormStatus } from 'react-dom';
import { sendMessage } from './mock-api.js';

const initialState = { kind: 'idle', message: '' };

function Fields({ text, setText }) {
  const { pending } = useFormStatus();
  return (
    <fieldset disabled={pending}>
      <label htmlFor="message">問い合わせ文(1〜200文字)</label>
      <textarea
        id="message" name="message" rows={5}
        value={text} onChange={event => setText(event.target.value)}
        aria-describedby="help result" required maxLength={200}
      />
      <button type="submit">{pending ? '送信中…' : '送信する'}</button>
    </fieldset>
  );
}

export default function App() {
  const [text, setText] = useState('');
  const [state, formAction, isPending] = useActionState(
    async (_previousState, formData) => {
      const raw = formData.get('message');
      const message = typeof raw === 'string' ? raw.trim() : '';
      if (!message || message.length > 200) {
        return { kind: 'error', message: '本文を1〜200文字で入力してください。' };
      }

      let result;
      try {
        result = await sendMessage(message);
      } catch {
        return { kind: 'error', message: '送信結果を確認できませんでした。入力は残しています。' };
      }
      if (!result.ok) return { kind: 'error', message: result.message };

      startTransition(() => setText(''));
      return { kind: 'success', message: result.message };
    },
    initialState
  );

  return (
    <main>
      <h1>送信状態を確かめるフォーム</h1>
      <p id="help">「重複」は受付エラー、「通信失敗」は通信エラーのデモです。</p>
      <form action={formAction} aria-busy={isPending}>
        <Fields text={text} setText={setText} />
        <p id="result" role="status" aria-live="polite">
          {isPending ? '結果を確認しています。' : state.message}
        </p>
      </form>
      <p>外部への送信や保存は行いません。</p>
    </main>
  );
}

style.css

* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #182238; background: #f1f5f9; }
main { max-width: 42rem; margin: 2rem auto; padding: 1.25rem; background: white; }
h1 { font-size: 1.5rem; }
fieldset { border: 0; padding: 0; margin: 0; }
label { display: block; margin-bottom: .5rem; }
textarea { width: 100%; font: inherit; padding: .75rem; }
button { display: block; margin-top: .75rem; padding: .6rem 1rem; font: inherit; }
:focus-visible { outline: 3px solid #2563eb; outline-offset: 3px; }
#result { min-height: 3em; }

ファイルを置いたフォルダで依存関係をインストールし、開発サーバーを起動します。

npm install
npm run dev

http://127.0.0.1:5207/を開いてください。まず普通の文章を入力して送信し、次に「重複」「通信失敗」をそれぞれ入力します。実際の問い合わせ内容や個人情報を用意する必要はありません。

Actionの正常完了と、受付成功を分けて入力を消す

App.jsxではtextareaをvalue={text}で制御しています。入力値はuseState、送信結果はuseActionStateという分担です。受付が成功した分岐だけでsetText('')を呼ぶため、検証エラーや通信失敗の分岐では文章が残ります。await後の更新はstartTransitionで包んでいます。

ここで非制御入力にしておくと、動きが変わります。formの公式説明では、Actionが成功すると非制御のフォーム要素がリセットされます。たとえばreturn { error: '受付失敗' }はアプリ側のエラー表現ですが、関数は例外を投げず正常に戻っています。この場合にも非制御入力が空へ戻ることを、別の小さなフォームで確認しました。

エラー表示のためだけに毎回throwする必要はありません。修正して再送できる受付エラーは結果stateとして表示し、入力を残す方針を明示します。このデモのcatchはモック送信の失敗を表示へ変換しています。実APIへ置き換える際は、想定する通信・受付エラーとプログラム上の不具合を区別し、後者を記録・調査できるようにします。

試す入力 結果 入力欄
空欄のまま送信 requiredによるブラウザ側の入力確認 そのまま
半角スペースだけ trim後に空として本文の入力を求める 入力したスペースが残る
重複 モックAPIの受付エラーを表示 「重複」が残る
通信失敗 結果を確認できなかったと表示 「通信失敗」が残る
仕様を教えてください。 デモ受付の成功を表示 空になる

送信中はfieldsetが無効になり、入力欄とボタンを操作できません。送信した文章を待機中に書き換えてしまい、成功時に新しい文章まで消すことを避けるためです。結果欄も前回の成功表示から「結果を確認しています。」へ切り替えます。

200文字の判定にはJavaScriptのlengthを使っています。これはUTF-16の長さであり、絵文字などでは見た目の1文字と一致しません。サービスの文字数仕様に合わせる場合は、ブラウザのmaxLengthと、クライアント・サーバーの判定方法をそろえてください。

useOptimisticとServer Functionは、必要な責務が増えたときに選ぶ

このフォームは受付の完了を待ってから成功と表示するため、useOptimisticを使っていません。useOptimisticは、確定する前の一時的な表示を扱うためのHookです。たとえば「いいね」を先に増やすUIを選ぶなら、失敗時に確定値へ戻ることも含めて設計します。処理を待つisPendingとは目的が異なります。

必要なこと 担当するもの
送信処理の結果と待機状態 useActionState
form内の送信ボタンなどで待機状態を読む useFormStatus
未確定の変更を先に画面へ反映する useOptimistic
入力文を編集し、消す条件を決める 今回のuseState
実際の保存・認証・重複登録防止 サーバー側の処理

Viteの今回のコードはブラウザで動きます。useActionStateを使っただけでサーバー処理になるわけではありません。Server Functionを扱うフレームワークでは、送信先の関数をサーバー側へ置く構成も選べますが、その機能を持たないViteのコードに'use server'を足すだけでは移行できません。この記事ではNext.jsなどのサーバー構成は実装・検証していません。

実際の受付APIへ置き換えるとき、ボタンのdisabledはUI上の連打を抑えるものです。別タブからの送信や再試行まで含む重複登録は、サーバー側で扱います。通信が途切れた場合も「未登録」とは限らないため、結果確認や重複防止の方針なしに自動再送しないようにします。

入力要素ごとのvalue・checkedの扱いはReactのフォーム要素の書き方、Promiseを読み取る処理はReactのuse APIを参照してください。送信フォームでは、まず「いつ待機に入り、失敗時に何を残し、成功時に何を消すか」を決めると、Hookの役割を組み立てやすくなります。

スポンサーリンク