Old-School Front-End · Frameworks / 2026 ・ No.06

いにしえのフロントエンド入門 フレームワーク編

第6回 フォームと入力チェック ── 制御と非制御、Actions、2回押し

公開

ブラウザの画面。React の申請フォームで「申請する」を押したところ。上に赤い一覧「入力内容を確認してください」が出て、件名を入力してください、2行目の金額は「14720」のように数字だけで入力してください、経費規程を確認して、チェックを入れてください、の3つ。件名の欄、2行目の金額「12,800円」、確認のチェックが赤くなっている
サーバーへ送る前に、画面で止めました。言葉はフロントエンド編のときと同じです(筆者がクラウド上の Linux で、2026年11月2日に撮影。ウインドウの上の帯は説明のために描き足したもの)

第5回で行は動くようになりましたが、まだ送れません。第6回は React 19 の Actions(useActionState・useFormStatus)で送信を付け、フロントエンド編 第9回・第10回と同じ決まりで、画面とサーバーの両方で調べます。
そのうえで、送ったら件名が消えた、2回押したら申請が2件できた、エラーが隣の行へ移った、という3つのつまずきを、わざと起こして測ります。

一

値はどこにあるか

新井送信なら、件名も備考も全部 useState にして、ボタンで fetch すれば終わりですよね。

古井全部を状態にする前に、いまどこに値があるかを見よう。第5回で状態にしたのは、明細だけだった。

全15回の路線図(いまここ:第6回)

1 なぜ FW 2 jQuery→MVC 3 React 以後 4 React の一歩 5 状態と再描画 6 フォーム 7 データ取得 8 再描画のコスト 9 状態の置き場所 10 画面遷移 11 楽観的更新 12 Server Comp. 13 キャッシュ 14 テスト 15 公開と選び方 いまここ なぜと歴史 React の基本 速さと置き場所 画面とサーバー Next.js 仕上げ

申請フォームの欄は、2種類に分かれています。明細の欄は value と onChange で状態につないだ制御された欄。件名・支払い方法・備考・確認のチェック・領収書のファイルは、値をブラウザに任せた非制御の欄です。第5回の五で測ったとおり、件名を打っても、どの部品も描きなおされません。

値はどこにあるか

状態に持つ(制御) value={row.amount} onChange 明細の 日付・区分・内容・金額・通貨 打つたびに描きなおす 合計・件数・エラーの行もここから ブラウザに任せる(非制御) name="title"(value なし) 件名・支払い方法・備考・確認 領収書のファイル(値は入れられない) 打っても描きなおさない FormData 送るとき、name で両方を集める ほかの部品が使う値だけを状態にする。送るときにだけ要る値は、ブラウザに任せてよい ただし <form action> は送ったあとで右側を空にする(六)

送るときは、どちらでもかまいません。ブラウザの 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 は「前の結果」と「フォームの中身」を受け取り、次の結果を返すだけ。結果が変わると、画面が描きなおされます。

送信の流れ

押す 申請する/下書き onSubmit FormData を作る startTransition formAction(中身) submitClaim Action 画面のチェック validateClaim return { errors } 送らない fetch /api/expenses 練習用サーバー 同じ決まりで、もう一度 次の result 400 errors/201 id 描く 一覧・赤い欄・番号 送っているあいだ ボタンは押せない だめ 通った 画面のチェックは速さのため、サーバーのチェックは守りのため。どちらも同じ決まり Action は「前の結果」と「中身」を受け取り、次の結果を返す関数。React が順に呼ぶ

ふつうは <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。練習用サーバー(ポート 3000)でログインすると 200。そのクッキーで、件名なし・金額 14720円 の申請をポート 5173 へ送ると、title 件名を入力してください、amount-1 1行目の金額は「14720」のように数字だけで入力してください、agree 経費規程を確認して、チェックを入れてください、が返った
画面を通さずに送っても、サーバーが同じ言葉で止めます(筆者がクラウド上の Linux で、2026年11月2日に撮影。ウインドウの上の帯は説明のために描き足したもの)

画面を通さずに curl で送ってみました。ログインなしは 401。ログインしたクッキーで送ると、件名なし・「14720円」・チェックなしの3つを、画面と同じ言葉で返しました。画面のチェックは飛ばせる。サーバーのチェックは飛ばせない。だから両方に置きます。

六

送ったら、件名が消えた

新井<form action> にしたら、「だめ」のあとで件名が空になりました。また打つんですか……。

古井領収書も選びなおしだな。3つの送り方で比べよう。

3つの小さなブラウザの画面。どれも、件名に大阪出張、金額に 15000、領収書に receipt.pdf、チェックを入れて送り、「だめ」と返ってきたあと。① form action では件名が空、ファイルは選択されていません、チェックは外れた。② defaultValue を足すと件名だけ戻った。③ onSubmit から送ると、件名・ファイル・チェックがすべて残った。金額はどれも 15000
同じ入力で、送り方だけを変えました。金額(状態につないだ欄)はどれでも残ります(筆者がクラウド上の Linux で、2026年11月2日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 件名・金額・領収書・チェックを入れて送り、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 に戻ります。

結 — 一枚まとめと、次回

  1. 状態にするのは、ほかの部品が使う値だけ。送るときは FormData が、制御・非制御の両方を集める。
  2. Action は「前の結果」と「中身」から次の結果を返す関数。useActionState が結果と呼び出し口を返す。
  3. 画面のチェックは送信0回・約0.02秒、サーバーは同じ言葉で約0.63秒。画面は飛ばせる、サーバーは飛ばせない。
  4. <form action> は「だめ」のときも欄を空にする。onSubmit から startTransition で呼べば残り、submitter も忘れずに。
  5. 2回押しは2件になる。useFormStatus を子の部品で読んで、送っているあいだは押せなくする。エラーは行の id で覚える。

新井送れました! でも、為替レートはまだ 151.23 の決め打ちのままです。

古井次回は副作用とデータ取得。useEffect でレートを取り、本物の Spring Boot につなぐ。CORS と CSRF と 401 が、まとめて待っている。

参考

記録は、筆者がクラウド上の 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

← 記事一覧へ