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

古法前端入门 框架篇

第6讲 表单与输入检查 ── 受控与非受控、Actions、按 2 次

发布

浏览器画面。在 React 的申请表单中点击了“提交申请”。上方出现红色列表“请检查输入内容”,有 3 条:请填写事由;第 2 行的金额请只填数字,例如“14720”;请阅读费用报销规定并勾选。事由栏、第 2 行金额“12,800日元”和确认勾选变红
在发送给服务器之前,先在画面上拦住。文字和前端篇时一样(笔者于 2026 年 11 月 2 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

第 5 讲里行已经能动了,但还发不出去。第 6 讲用 React 19 的 Actions(useActionState・useFormStatus)加上发送,并用和前端篇第 9・10 讲相同的规则,在画面和服务器两边检查。
然后故意制造 3 个问题来测量:发送后事由没了、按 2 次产生 2 件申请、错误跑到了旁边的行。

一

值在哪里

新井要发送的话,把事由、备注全都做成 useState,按钮里 fetch 一下就完了吧。

古井全做成状态之前,先看看现在值在哪里。第 5 讲放进状态的只有明细。

全 15 讲路线图(现在:第 6 讲)

1 为什么要框架 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”。
  请阅读费用报销规定并勾选。

画面的检查一次也没发给服务器,约 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)完成。cookie 不区分端口,所以在 3000 拿到的 cookie 也会带到 5173。

终端画面。用 curl 不登录发送得到 401。在练习用服务器(端口 3001)登录得到 200。用这个 cookie 把没有事由、金额为 14720円 的申请发送到端口 5174,返回 title 请填写事由;amount-1 第 1 行的金额请只填数字,例如“14720”;agree 请阅读费用报销规定并勾选
不经过画面直接发送,服务器也会用同样的文字拦住(笔者于 2026 年 11 月 2 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

试着不经过画面,用 curl 发送。不登录是 401。用登录后的 cookie 发送,没有事由・“14720円”・没有勾选这 3 处,服务器用和画面相同的文字返回。画面的检查能跳过,服务器的检查跳不过。所以两边都要放。

六

发送后,事由没了

新井改成 <form action> 以后,“不行”之后事由变空了。还得再输一遍吗……

古井收据也得重新选。用 3 种发送方式比一比。

3 个小浏览器画面。都是在事由填大阪出差、金额填 15000、收据选 receipt.pdf、打上勾后发送,返回“不行”之后。① form action 时事由为空,文件未选择,勾选被取消。② 加上 defaultValue 后只有事由回来了。③ 从 onSubmit 发送时,事由・文件・勾选全部保留。金额都是 15000
同样的输入,只改变发送方式。金额(连到状态的栏)哪种都保留(笔者于 2026 年 11 月 2 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)
# 填好事由・金额・收据・勾选后发送,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 主动请求。

③ 有一点要注意。只用 new FormData(form),按下的按钮(提交/草稿)的值不会放进去,是 null。把 event.nativeEvent.submitter 作为第 2 个参数传入就有了。

七

按 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 等第 1 个的回复后才发送第 2 个(0 ms 和 603 ms)
# 申请表单(SendButtons)双击:发送 1 件。发送期间按钮显示“正在发送…”,不能按

什么都不做的话,双击就产生了2 件申请。想在审批的人把同一笔新干线费用批两次之前拦住。useActionState 会等第 1 个的回复再发第 2 个(遵守顺序),但不会停止发送本身。要拦住,就是在发送期间让按钮不能按。

/** 保存草稿和提交的按钮。发送期间不能按(按 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 以 JSON 接收 1 件费用,用 Bean Validation 的标注检查。规则大多形式相同,但也有偏差。

  • 画面没有的规则 ── 内容的长度(100 个字)和超过 5 万日元要理由。第 7 讲连接真正的服务器之前,在画面这边也补上
  • 读法的不同 ── 画面会把全角的“12800”改正后通过,后端在 JSON 阶段就是 400。发送画面改正后的值比较安全
  • “今天” ── 画面的今天是电脑的时钟,服务器的今天是服务器的时钟(后端篇第 6 讲第七节)。在日期交界处,画面通过了,服务器也可能拒绝

画面再怎么对齐,最后决定的是服务器。把画面看成“在被服务器拒绝之前,提早告诉你”的角色,有偏差也不要紧。

九

在自己的电脑上

用 node server15.mjs zh 3001 启动练习用服务器,在 localhost:3001 登录,然后在 React 项目里用 KEIHI_API=http://localhost:3001 npm run dev 启动,打开 ?lang=zh。把事由清空后点击“提交申请”,开发者工具的 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 正一起等着呢。

参考

记录是笔者于 2026 年 11 月 2 日在云端 Linux 上取得的。在第 5 讲的项目(Vite 8.3.5、React 19.3.1)上加入发送和检查,用 Vite 的 server.proxy 转给前端篇的练习用服务器(Node.js,端口 3000〜3002)。开发用(npm run dev)和生产用(npm run build 后用 npm run preview 提供)都用 Playwright 驱动的 Chromium 153.0.8010.12 打开。时间是生产用测 5 次的中位数。第六・七节的实验在 lab.html 的另一个画面进行。

古法前端入门 框架篇 第6讲 — 表单与输入检查 / 2026.11

← 返回文章列表