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の役割を組み立てやすくなります。