Old-School Front-End · Frameworks / 2026 ・ No.06
いにしえのフロントエンド入門 フレームワーク編
第6回 フォームと入力チェック ── 制御と非制御、Actions、2回押し
公開
第5回で行は動くようになりましたが、まだ送れません。第6回は React 19 の Actions(useActionState・useFormStatus)で送信を付け、フロントエンド編 第9回・第10回と同じ決まりで、画面とサーバーの両方で調べます。
そのうえで、送ったら件名が消えた、2回押したら申請が2件できた、エラーが隣の行へ移った、という3つのつまずきを、わざと起こして測ります。
値はどこにあるか
新井送信なら、件名も備考も全部 useState にして、ボタンで fetch すれば終わりですよね。
古井全部を状態にする前に、いまどこに値があるかを見よう。第5回で状態にしたのは、明細だけだった。
全15回の路線図(いまここ:第6回)
申請フォームの欄は、2種類に分かれています。明細の欄は value と onChange で状態につないだ制御された欄。件名・支払い方法・備考・確認のチェック・領収書のファイルは、値をブラウザに任せた非制御の欄です。第5回の五で測ったとおり、件名を打っても、どの部品も描きなおされません。
値はどこにあるか
送るときは、どちらでもかまいません。ブラウザの FormData が、name を手がかりに両方を集めてくれます。状態にするのは、ほかの部品が使う値(合計・件数・エラーの行)だけで十分です。ファイルの欄は、そもそもプログラムから値を入れられないので、非制御にするしかありません。
Actions で送る
/** 'amount-2'(2行目の金額)を、送ったときの2行目の id につけかえる。行を消しても、エラーが隣へ移らないように */
function tie(errors, ids) {
return errors.map((e) => {
const m = /^(date|category|item|amount)-(\d+)$/.exec(e.field);
return { ...e, key: m ? `${m[1]}:${ids[Number(m[2]) - 1] ?? ids[0]}` : e.field };
});
}
/** 送るときの仕事(Action)。前の結果と、フォームの中身(FormData)を受け取り、次の結果を返す */
async function submitClaim(prev, formData) {
const ids = formData.getAll('rowId').map(Number);
const draft = formData.get('action') === 'draft'; // 下書きは、チェックせずに預ける
const at = prev.at + 1;
if (!draft && CHECK) {
const errors = validateClaim(claimOf(formData), localDate(), RATE);
if (errors.length > 0) return { errors: tie(errors, ids), at, from: 'client' };
}
const r = await sendClaim(formData);
return { ...r, errors: tie(r.errors ?? [], ids), at, from: 'server' };
}
/** 申請フォームの画面。明細の状態(rows)は、ここで1つだけ持つ */
export default function App() {
countRender('App');
const [rows, setRows] = useState(SAMPLE_ROWS);
const [result, formAction] = useActionState(submitClaim, { errors: [], at: 0 });
// …(change・add・remove は第5回のまま)
// <form action={…}> にすると、React は送ったあとで欄を空にする(エラーのときも)。
// ここでは onSubmit で受けて、ブラウザにも React にも空にさせず、自分で formAction を呼ぶ
const onSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget, event.nativeEvent.submitter); // 押したボタン(申請/下書き)も入れる
startTransition(() => formAction(formData));
};
return ( … <form onSubmit={onSubmit} noValidate> … </form> … );
}
React 19 では、送るときの仕事を Action という関数にまとめます。useActionState(submitClaim, 初めの値) は、いまの結果 result と、Action を呼ぶ関数 formAction を返します。submitClaim は「前の結果」と「フォームの中身」を受け取り、次の結果を返すだけ。結果が変わると、画面が描きなおされます。
送信の流れ
ふつうは <form action={formAction}> と書くだけで動きます。このアプリでは、わざと onSubmit で受けて、自分で startTransition の中から formAction を呼んでいます。理由は六で測ります。
画面で先に調べる
/** FormData から申請の中身を作る(練習用サーバーと同じ読み方) */
export function claimOf(formData) {
const all = (k) => formData.getAll(k).map(String);
const [d, c, i, a, cur] = ['date', 'category', 'item', 'amount', 'currency'].map(all);
return {
title: String(formData.get('title') ?? ''),
rows: d.map((_, n) => ({ date: d[n], category: c[n], item: i[n], amount: a[n], currency: cur[n] })),
receipts: formData.getAll('receipt').filter((f) => f.size > 0).length,
agreed: formData.get('agree') === 'yes',
};
}
/** 申請の中身を調べて、[{ field: 'amount-2', message: '…' }, …] を返す(空の配列なら合格) */
export function validateClaim(claim, today = localDate(), rate = null) {
const errors = [];
// …(フロントエンド編 第9回・第10回と同じ決まり)
}
決まりは、フロントエンド編 第9回・第10回の validate.js を、そのまま import できる形にしただけです。練習用サーバーも同じ validate.js で調べなおすので、言葉まで同じになります。FormData の読み方も、サーバーとそろえました。
# 同じまちがい(件名なし・2行目の金額に「円」・チェックなし)で「申請する」を押した
画面のチェックサーバーのチェック(?check=off)
サーバーへの送信0 1
一覧が出るまで21.9 ms 627 ms
一覧の項目3 2(件名は打ってあった)
フォーカス#error-summary #error-summary
# 時間は本番用のビルドで5回測った中央値。サーバー側の 600 ms は、練習用サーバーがわざと待たせている分
# 言葉はどちらも同じ(同じ決まり・同じ文)。下は、サーバーが返した一覧
2行目の金額は「14720」のように数字だけで入力してください。
経費規程を確認して、チェックを入れてください。
画面のチェックは、サーバーへ1回も送らずに約0.02秒で一覧を出しました。サーバーのチェックは約0.63秒(ほとんどは練習用サーバーがわざと待つ分)。どちらも同じ言葉です。画面のチェックは速さと親切のため、サーバーのチェックは守りのため。画面のチェックは五で見るように、だれでも飛ばせます。
# npm run build の JavaScript(第5回 → 第6回) index-*.js 229.71 kB → 239.35 kB gzip 72.57 kB → 75.95 kB # 増えた 3.4 kB(gzip)は、チェックの決まりと3言語の言葉。React の Actions は React 本体にもともと入っている
まちがいを見せる
/** 入力のまちがいの一覧。出たときに、ここへフォーカスを移す(読み上げソフトにも、まず一覧を読ませる) */
export default function ErrorSummary({ errors }) {
const go = (event, key) => { // 一覧の項目を押すと、その欄へ
event.preventDefault();
document.getElementById(key.replace(':', '-r'))?.focus();
};
return (
<div className="alert alert-danger" id="error-summary" tabIndex={-1} ref={(el) => el?.focus()}>
<h2 className="h6 alert-heading">{T.check}</h2>
<ul className="mb-0">
{errors.map((e) => (
<li key={e.key + e.message} id={`err-${e.key.replace(':', '-r')}`}>
<a href={`#${e.key.replace(':', '-r')}`} className="alert-link" onClick={(event) => go(event, e.key)}>{e.message}</a>
</li>
))}
</ul>
</div>
);
}
見せ方は、フロントエンド編 第9回と同じです。上に一覧を出してフォーカスを移し、欄には aria-invalid と、一覧の項目への aria-describedby を付けます。フォーカスを移すのに useEffect は使っていません。一覧の ref に関数を渡すと、一覧が画面に出たときに呼ばれます。送るたびに key を変えて一覧を作りなおすので、2回目の「だめ」でもフォーカスが戻ります。
困ったのは、サーバーの返事が amount-2(2行目の金額)という何番目かで来ることです。第5回の key と同じ落とし穴が、ここにもありました。
# サーバーは「amount-2(2行目の金額)がだめ」と返した。そのあとで1行目を消した エラーの覚え方消す前に赤い行消したあとに赤い行 何番目か(2)ホテル 1泊取引先との昼食(ずれた) 行の id(送ったときの2行目)ホテル 1泊ホテル 1泊 # 申請フォームでも同じ:id で覚えたので、1行目を消しても赤はホテルの金額に残った
何番目かのまま覚えると、1行目を消したとたんに赤い枠が昼食へ移りました。そこで、各行に <input type="hidden" name="rowId"> を入れて送り、Action の中で「送ったときの2行目」の id につけかえています(二の tie)。サーバーに手を入れなくても、画面の側で直せます。
練習用サーバーへ取り次ぐ
送り先は、フロントエンド編の練習用サーバー(ポート 3000)です。React の開発用のサーバーは 5173 なので、そのままではちがう所(オリジン)になります。そこで Vite に取り次ぎを頼みます。
export default defineConfig({
plugins: [react()],
server: {
// 第6回:/api で始まる呼び出しを、練習用サーバーへ取り次ぐ(ブラウザから見ると同じ所)
proxy: { '/api': process.env.KEIHI_API ?? 'http://localhost:3000' },
},
})
import { T } from './text.js';
export async function sendClaim(formData) {
const res = await fetch('/api/expenses', { method: 'POST', body: formData });
const body = await res.json().catch(() => ({}));
if (res.status === 201) return { id: body.id, draft: Boolean(body.draft) };
if (res.status === 400 && Array.isArray(body.errors)) return { errors: body.errors };
if (res.status === 401) return { message: T.signedOut }; // ログインのつまずきは第7回で
return { message: T.failed(res.status) };
}
ブラウザから見ると、送り先は同じ 5173 の /api/expenses です。CORS も CSRF も、まだ出てきません(本物の Spring Boot につなぐ第7回で)。ログインは、練習用サーバーの画面(localhost:3000)で済ませておきます。クッキーはポートを区別しないので、3000 でもらったクッキーが 5173 にも付きます。
画面を通さずに curl で送ってみました。ログインなしは 401。ログインしたクッキーで送ると、件名なし・「14720円」・チェックなしの3つを、画面と同じ言葉で返しました。画面のチェックは飛ばせる。サーバーのチェックは飛ばせない。だから両方に置きます。
送ったら、件名が消えた
新井<form action> にしたら、「だめ」のあとで件名が空になりました。また打つんですか……。
古井領収書も選びなおしだな。3つの送り方で比べよう。
# 件名・金額・領収書・チェックを入れて送り、Action が600ミリ秒後に「だめ」と返したあと 送り方件名金額領収書チェック ① <form action>空残った0外れた ② ① + defaultValue残った残った0外れた ③ onSubmit から送る残った残った1残った # 金額は状態につないだ欄(value)なので、どれでも残る。領収書の数字は、選ばれたファイルの数 子の部品の useFormStatus<form> を描く部品の useFormStatusisPending ① <form action>true → falsefalsetrue → false ③ onSubmit から送るtrue → falsefalsetrue → false # どちらも pending は 603 ms 続いた。押したボタンの値は、new FormData(form) だけだと null、submitter を渡すと "submit"
① <form action={formAction}> にすると、React は Action が終わったあとで、非制御の欄をすべて空にします。成功したときのための親切ですが、Action が「だめ」を返したときも、React から見れば「終わった」ので空にします。件名も、選んだファイルも、チェックも消えました。
② よく紹介される直し方は、Action が受け取った値を結果に入れて返し、defaultValue で戻すことです。件名は戻りましたが、ファイルは戻せません(プログラムから値を入れられない欄だから)。チェックも戻すなら、欄ごとに書き足すことになります。
③ onSubmit で preventDefault() し、startTransition の中で formAction を呼ぶと、だれも欄を空にしません。それでも useFormStatus の pending は ① と同じように動きました。React は、onSubmit の中で始まった送信も、その <form> の送信として扱うからです。このアプリは③にしました。成功したあとに空にしたいときは、requestFormReset で自分から頼めます。
③で1つ気をつけることがあります。new FormData(form) だけでは、押したボタン(申請/下書き)の値が入らず null でした。2つ目の引数に event.nativeEvent.submitter を渡すと入ります。
2回押すと、申請も2件
# lab.html?lab=double。いつも通る申請を、練習用サーバーへ本当に送った 書き方 ダブルクリック 0.15秒あけて2回 onSubmit で毎回 fetch2件 0013 00142件 0015 0016 useActionState(ボタンはそのまま)2件 0017 00182件 0019 0020 useActionState + pending で押せなく1件 00211件 0022 # 数字は申請の番号(EXP-2026-…)。useActionState は2つ目を、1つ目の返事を待ってから送った(0 ms と 603 ms) # 申請フォーム(SendButtons)でダブルクリック:送信 1 件。送っているあいだのボタンは「送信中…」で押せない
何もしないと、ダブルクリックで申請が2件できました。承認する人が、同じ新幹線代を2回認めてしまう前に止めたいところです。useActionState は2つ目を1つ目の返事を待ってから送りますが(順番は守る)、送ること自体はやめません。止めるのは、送っているあいだボタンを押せなくすることです。
/** 下書き保存と申請のボタン。送っているあいだは押せない(2回押しても、申請は1件) */
export default function SendButtons() {
countRender('SendButtons');
const { pending, data } = useFormStatus(); // いちばん近い親の <form> が、いま送っているか。data は送っている中身
const which = pending ? data?.get('action') : null;
return (
<div className="d-flex flex-wrap justify-content-end gap-2">
<button type="submit" className="btn btn-outline-secondary" name="action" value="draft" disabled={pending}>{which === 'draft' ? T.sending : T.draft}</button>
<button type="submit" className="btn btn-brand" name="action" value="submit" disabled={pending}>{which === 'submit' ? T.sending : T.submit}</button>
</div>
);
}
useFormStatus は、いちばん近い親の <form> が送っているかを教えてくれます。六の表の真ん中の列のとおり、<form> を描いている部品そのもの(App)で呼ぶと、ずっと false でした。だからボタンだけを小さな部品に分けます。data には送っている中身が入っているので、押したほうのボタンだけを「送信中…」にできます。
バックエンド編 第6回と並べる
# 同じ「だめ」を、画面(validate.js・練習用サーバー)と バックエンド編 第6回(Bean Validation)で書くと 決まり画面・練習用サーバーバックエンド第6回 日付が空date === '' @NotNull 日付が未来date > today @PastOrPresent 内容が空・空白だけitem.trim() === '' @NotBlank(strip) 内容が長すぎる(なし)@Size(max = 100) 金額が空toAmount → null @NotNull 金額が数字でないtoAmount → NaN JSON を読めず 400 金額が 0amount === 0 @Positive 金額の桁円は整数・ドルは2桁@Digits(9, 0) 3万円以上で領収書needsReceipt (なし) 5万円超で理由(なし)@AssertTrue 規程の確認agreed (なし) # 返事の形も近い:どちらも 400 と [{ field, message }] の一覧(バックエンドは ProblemDetail の errors)
バックエンド編 第6回の Spring Boot は、1件の経費を JSON で受け取り、Bean Validation の印で調べました。決まりの多くは同じ形ですが、ずれもあります。
- 画面にない決まり ── 内容の長さ(100文字)と、5万円を超えたら理由。第7回で本物につなぐ前に、画面の側にも足す
- 読み方のちがい ── 画面は全角の「12800」を直して通すが、バックエンドは JSON の段階で 400。画面で直した値を送るのが安全
- 「今日」 ── 画面の今日はパソコンの時計、サーバーの今日はサーバーの時計(バックエンド編 第6回の七)。日付の境目では、画面で通ってもサーバーで断られることがある
どこまで画面でそろえても、最後に決めるのはサーバーです。画面は「サーバーに断られる前に、早めに教える」係だと考えると、ずれても困りません。
自分の PC で
練習用サーバーを node server15.mjs ja 3000 で起こし、localhost:3000 でログインしてから、React のプロジェクトで npm run dev を実行します。件名を空にして「申請する」を押すと、開発者ツールの Network タブに何も出ないまま、一覧が出ます。URL に ?check=off を付けると、今度は /api/expenses が 400 で返ってきます。
六の実験は /lab.html?lab=reset&how=action(values・submit も)、七は ?lab=double&how=plain です。七は本当に申請を作るので、練習用サーバーを起こしなおすと番号が 0013 に戻ります。
結 — 一枚まとめと、次回
- 状態にするのは、ほかの部品が使う値だけ。送るときは FormData が、制御・非制御の両方を集める。
- Action は「前の結果」と「中身」から次の結果を返す関数。useActionState が結果と呼び出し口を返す。
- 画面のチェックは送信0回・約0.02秒、サーバーは同じ言葉で約0.63秒。画面は飛ばせる、サーバーは飛ばせない。
- <form action> は「だめ」のときも欄を空にする。onSubmit から startTransition で呼べば残り、submitter も忘れずに。
- 2回押しは2件になる。useFormStatus を子の部品で読んで、送っているあいだは押せなくする。エラーは行の id で覚える。
新井送れました! でも、為替レートはまだ 151.23 の決め打ちのままです。
古井次回は副作用とデータ取得。useEffect でレートを取り、本物の Spring Boot につなぐ。CORS と CSRF と 401 が、まとめて待っている。
参考
- React — useActionState
- React — useFormStatus
- React — <form>
- React — <input>(controlled / uncontrolled)
- React — startTransition
- React — requestFormReset
- MDN — FormData() constructor(submitter)
- Vite — server.proxy
記録は、筆者がクラウド上の Linux で、2026年11月2日に取ったものです。第5回のプロジェクト(Vite 8.3.5、React 19.3.1)に送信とチェックを足し、フロントエンド編の練習用サーバー(Node.js、ポート 3000〜3002)へ Vite の server.proxy で取り次ぎました。開発用(npm run dev)と本番用(npm run build を npm run preview で配ったもの)を、Playwright で動かした Chromium 153.0.8010.12 で開いています。時間は本番用で5回測った中央値です。六・七の実験は lab.html の別の画面で行いました。
いにしえのフロントエンド入門 フレームワーク編 第6回 — フォームと入力チェック / 2026.11