news 2026/9/20 10:15:30

React Starter Kit 表单与校验实战:受控组件、Zod 共享与多步骤状态机模式

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
React Starter Kit 表单与校验实战:受控组件、Zod 共享与多步骤状态机模式
  • 后端
  • 前端

【免费下载链接】react-starter-kit

Modern React starter kit with Bun, TypeScript, Tailwind CSS, tRPC, Stripe, and Cloudflare Workers. Production-ready monorepo for building fast web apps.

项目地址:https://gitcode.com/gh_mirrors/rea/react-starter-kit
点击查看免费下载

表单是 Web 应用与用户交互最密集的环节,也是校验、错误处理、加载态协调最容易失控的地方。本文基于 react-starter-kit 仓库中 docs/frontend/forms.md 的实践沉淀,系统讲解这套 TypeScript + React + TanStack Query 技术栈下的表单方案:不引入任何表单库,用受控 React 输入 + Zod 校验的直白方式,配合 mutation hook 完成提交;并用登录/注册多步骤表单(method → email → otp状态机)展示复杂交互下的状态编排。读完你将掌握:应用层表单的基本写法、Zod schema 前后端共享、role="alert"可访问性错误提示、防重复提交的加载态协调,以及"表单只负责提交、缓存失效与导航交给调用方"的职责划分原则。

设计取向:为什么不用表单库

原文档开篇就明确了技术决策:表单使用受控 React 输入 + Zod 校验,不引入表单库——模式足够简单,直接用更直白的写法反而让数据流清晰可见。

这意味着:

  • 输入值由组件自身的useState持有,valueonChange显式绑定,没有隐式的表单上下文;
  • 校验在两层进行:服务端 tRPC procedure 的 Zod input schema 是最终防线,前端 Zod schema 共享用于搜索参数清洗或即时检查;
  • 提交通过 TanStack Query 的 mutation hook 完成,错误、加载态都由 mutation 对象直接暴露。

从仓库看,apps/app/routes/(app)/members.tsx中的CreateOrganizationCardapps/app/components/auth/auth-form.tsx均遵循这一模式,没有引入任何表单库依赖。

基本模式:受控输入 + mutation hook

以一个"创建项目"表单为例,useCreateProject来自 Add a tRPC Procedure(仓库并未内置项目相关 tRPC mutation,需要按该教程先构建),基本形态如下:

import { useCreateProject } from "@/lib/queries/project"; import { Button, Input, Label } from "@repo/ui"; import { useId, useState } from "react"; function CreateProjectForm() { const [name, setName] = useState(""); const nameId = useId(); const createProject = useCreateProject(); return ( <form onSubmit={(e) => { e.preventDefault(); // Clearing the input belongs to this form, not to every caller of the // hook, so it rides on the call rather than the module's onSuccess. createProject.mutate({ name }, { onSuccess: () => setName("") }); }} > <Label htmlFor={nameId}>Project name</Label> <Input id={nameId} value={name} onChange={(e) => setName(e.target.value)} required aria-invalid={Boolean(createProject.error)} /> {createProject.error && ( <p role="alert" className="text-sm text-destructive"> {createProject.error.message} </p> )} <Button type="submit" disabled={createProject.isPending}> {createProject.isPending ? "Creating..." : "Create"} </Button> </form> ); }

这个例子里有几个值得细读的设计细节:

  1. useId关联 Label 与 InputnameId由 React 的useId()生成,避免硬编码 id 造成的多实例冲突;
  2. 提交成功后的清空输入属于表单职责:代码注释点明——清空输入是"这个表单"自己的行为,而不是每个调用方都应共享的副作用,所以挂在mutate调用的onSuccess回调上,而不是 query 模块的onSuccess上。这与后文"模块拥有缓存失效、调用方拥有导航"的职责划分一脉相承;
  3. aria-invalid+role="alert":服务端校验失败发生在 submit 之后,必须通过role="alert"让屏幕阅读器主动播报——这是可访问性上的硬性要求。

该模式在members.tsxCreateOrganizationCard(apps/app/routes/(app)/members.tsx/members.tsx#L106-L163))中有完全一致的真实实现:aria-invalidaria-describedby关联错误段落、提交时禁用按钮。

支撑它的 query 模块

useCreateProject这类 hook 由 query 模块封装,职责是持有 mutation 并在成功后失效相关缓存(见 docs/recipes/new-procedure.md 与 apps/app/lib/queries/organization.ts):

export const projectQueryKey = ["project"] as const; export function useCreateProject() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (input: { name: string; description?: string }) => trpcClient.project.create.mutate(input), onSuccess: () => queryClient.invalidateQueries({ queryKey: projectQueryKey }), }); }

注意:缓存失效由模块声明invalidateQueries({ queryKey: projectQueryKey })),表单组件只负责调用mutate和展示状态。这样任何页面复用同一 hook 都能获得一致的缓存更新行为。

organization.tsuseCreateOrganization更进一步展示了 mutation 的边界处理:onSuccess里调用revalidateSession(queryClient, router),因为"活跃组织"存在 session 行上,必须先刷新 session 缓存,组织域查询才能看到新组织;且onSuccessreturned(返回而非 fire-and-forget),保证 mutation 在 session 带上新组织之前保持 pending,否则表单已结束而页面仍显示"no active organization"。

Zod Schema 共享:前端搜索参数校验

Zod schema 定义在 tRPC procedure 上,可以共享给前端做搜索参数校验或客户端检查。仓库中最典型的例子是登录路由用 Zod schema +validateSearch解析期清洗returnTo参数(完整示例见 Routing > Search Params,实现于 apps/app/routes/(auth)/login.tsx/login.tsx#L19-L28)):

const searchSchema = z.object({ returnTo: z .string() .optional() .transform((val) => { const safe = getSafeRedirectUrl(val); return safe === "/" ? undefined : safe; }) .catch(undefined), });

关键点:

  • .transform在解析期调用getSafeRedirectUrl完成开放重定向防护——非法值一律回落为安全路径;
  • .catch(undefined)保证任何解析异常都不会让路由渲染崩溃,而是得到一个undefined
  • 组件侧通过Route.useSearch()拿到的是已经清洗过的值:search.returnTo保证安全,可直接用于导航,消费方无需再校验。

这体现了 Zod schema 共享的典型收益:同一份 schema 既能约束服务端 tRPC 输入,也能在客户端路由解析阶段做即时清洗,前后端校验逻辑不分裂。

多步骤表单:useAuthForm 状态机

登录/注册表单(apps/app/components/auth/auth-form.tsx)是仓库中最复杂的表单,演示了多步骤表单模式。它用一个最小状态机管理三个步骤:

method → email → otp ↑ ↑ │ └────────┘ │ ←───────┘

状态迁移表定义在useAuthForm(apps/app/components/auth/use-auth-form.ts):

const VALID_TRANSITIONS: Record<AuthStep, AuthStep[]> = { method: ["email"], email: ["method", "otp"], otp: ["email"], };

transitionTo依据这张表做合法性校验,非法跳转直接忽略

const transitionTo = useCallback((next: AuthStep, clearErr = true) => { const current = stepRef.current; if (!VALID_TRANSITIONS[current].includes(next)) { return; } if (next === "method") { hasSucceededRef.current = false; } setStep(next); if (clearErr) setError(null); }, []);

两个实现细节值得注意:

  • stepRef避免过期闭包transitionTouseCallback记忆化的,通过 ref 读取当前步骤,避免回调闭包捕获到旧的step值;
  • 回到method重置成功守卫hasSucceededRef.current = false,允许重新发起一次完整的认证流程。

步骤的条件渲染

AuthForm组件根据当前状态条件渲染各步骤(apps/app/components/auth/auth-form.tsx):

export function AuthForm({ mode = "login", onSuccess, returnTo }) { const { step, email, isDisabled, error /* actions */ } = useAuthForm({ onSuccess, mode, }); return ( <div className="flex flex-col gap-6 w-full"> {error && ( <div role="alert" className="rounded-md bg-destructive/10 p-3 text-sm text-destructive" > {error} </div> )} {step === "method" && <MethodSelection /* ... */ />} {step === "email" && <EmailInput /* ... */ />} {step === "otp" && <OtpStep /* ... */ />} </div> ); }

三个步骤组件各司其职:

  • MethodSelection:展示社交登录(Google,按useSocialProviders()结果条件渲染)、"Continue with email"、以及仅登录模式才出现的 Passkey 登录(passkey 依赖已有账户);
  • EmailInput:邮箱输入 + 提交按钮 + 返回链接,autoComplete="email"autoFocus
  • OtpStep:内嵌 OtpVerification,包含 30 秒重发冷却倒计时、6 位数字输入过滤(replace(/\D/g, "").slice(0, 6))与错误码映射(TOO_MANY_ATTEMPTSOTP_EXPIREDINVALID_OTP)。

四个关键设计决策

useAuthForm注释中明确了几个刻意的设计选择:

  • Counter-based pending ops:用计数器而非布尔值跟踪子操作,正确处理重叠的子操作(如快速双击导致的并发请求)。setChildBusysetPendingOps((c) => (busy ? c + 1 : Math.max(0, c - 1)))增减计数;
  • Success guard(hasSucceededRef:防止多种认证方式(Google、Passkey、OTP)并发完成导致重复的 post-auth 流程。onAuthSuccess开头if (hasSucceededRef.current) return;直接拦截;注释还解释了为何用 ref 而非 state——禁用状态是异步生效的,两次完成可能落在 React 重渲染之前;
  • Email 规范化:在调用 API 前email.trim().toLowerCase(),避免大小写/空白不匹配;
  • 错误与步骤正交:错误可能发生在任何步骤,因此错误在表单层级统一展示,而不是绑定在某个步骤内部。

统一加载态

useAuthForm把自身请求与所有子操作的 loading 折叠成一个标志:

// useAuthForm folds its own request and every child's into one flag const isDisabled = isLoading || pendingOps > 0;

这个标志应用到所有可交互元素,防止流程进行中的重复提交:

<Input disabled={isDisabled} /> <Button type="submit" disabled={isDisabled || !email.trim()}> Continue </Button>

对于 mutation 场景,则直接使用 mutation 对象的isPending

<Button type="submit" disabled={mutation.isPending}> {mutation.isPending ? "Saving..." : "Save"} </Button>

从源码看,OTP 步骤的重发按钮还叠加了冷却逻辑(disabled={disabled || resendCooldown > 0},文案随倒计时动态显示Resend code in ${resendCooldown}s),可见"禁用态"是防重复提交的第一道闸门,在仓库所有表单中都被贯彻。

错误显示:role="alert" 的双层用法

错误展示统一为 alert 盒子 +role="alert",确保屏幕阅读器主动播报:

{ error && ( <div role="alert" className="rounded-md bg-destructive/10 p-3 text-sm text-destructive" > {error} </div> ); }

对于 mutation 错误,检查mutation.error

{ mutation.error && ( <div role="alert" className="text-sm text-destructive"> {mutation.error.message} </div> ); }

members.tsxCreateOrganizationCard在此基础上加了aria-describedby关联错误段落,配合aria-invalid,形成完整的表单错误可访问性闭环。设计要点:提交型错误发生在 submit 之后、不在用户输入焦点上,必须用role="alert"主动通知,这也是文档反复强调该属性的原因。

提交成功之后:调用方接管导航与缓存

表单提交成功后,缓存失效与导航由调用方处理,而不是表单自身——这是保持表单可复用的关键。登录页的调用(apps/app/routes/(auth)/login.tsx/login.tsx#L62-L74)):

// apps/app/routes/(auth)/login.tsx async function handleSuccess() { await revalidateSession(queryClient, router); await router.navigate({ to: search.returnTo ?? "/" }); } <AuthForm mode="login" onSuccess={handleSuccess} returnTo={search.returnTo} />;

这里有两个要点:

  1. revalidateSession先于导航:它removeQueries({ queryKey: sessionQueryKey })清掉 session 缓存,再router.invalidate()重跑路由守卫(见 apps/app/lib/queries/session.ts)。注释指出这里用removeQueries而非invalidateQueries,是为了让beforeLoad看到undefined重新拉取,而不是复用旧的 session;
  2. returnTo来自已验证的搜索参数search.returnTo已经过前文 Zod schema 清洗,可直接拼入导航目标。

因为调用方决定成功后的行为,AuthForm同时支撑登录页和注册页两个路由(mode="login" | "signup"仅影响文案与 Passkey 可用性,两者走同一套 OTP 流程)——这就是职责分离带来的直接复用收益。

模式小结与适用边界

回顾整套表单实践,可以提炼出几条可复用的规则:

关注点归属依据
输入状态表单组件useState受控输入基本模式
服务端校验tRPC procedure 的 Zod inputdocs/api/validation-errors.md
前端即时校验/清洗共享 Zod schema +validateSearchlogin 路由的returnTo清洗
提交逻辑与缓存失效query 模块的 mutation hookapps/app/lib/queries/organization.ts
成功后的导航调用方onSuccesslogin 页的handleSuccess
错误播报role="alert"盒子所有表单统一模式
防重复提交统一isDisabled/ mutationisPendinguseAuthFormOtpVerification

这套方案的适用前提是表单形态相对直接:单步提交、字段数量有限、校验集中在提交点。当表单出现复杂的字段联动、动态校验、条件显隐时,再评估是否需要引入表单库;而像登录/注册这样的多步骤流程,仓库展示的是用显式状态机 + 受控组件替代表单库的做法——状态迁移表、成功守卫、计数器式 loading 这三个机制,就是它应对复杂度的全部家当。

进一步阅读:表单背后的数据获取与缓存模式见 docs/frontend/state.md;如何从零新增一个带校验的 tRPC procedure 见 docs/recipes/new-procedure.md;路由搜索参数校验的完整上下文见 docs/frontend/routing.md。

  • 后端
  • 前端

【免费下载链接】react-starter-kit

Modern React starter kit with Bun, TypeScript, Tailwind CSS, tRPC, Stripe, and Cloudflare Workers. Production-ready monorepo for building fast web apps.

项目地址:https://gitcode.com/gh_mirrors/rea/react-starter-kit
点击查看免费下载

相关推荐

上一篇:Matter Telink 灯光开关示例应用:构建、Thread 配网、Binding 集群与 OTA 实战指南
下一篇:gRPC微服务通信:Spring Boot集成gRPC远程调用完整指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 10:15:15

小学生学C++几年级开始合适

小学生学C的黄金启动窗口是四年级到五年级&#xff0c;完全适配你家孩子当前的四年级节奏&#xff0c;不同年级的适配性差异非常明确&#xff1a; ❌ 1-3年级&#xff1a;绝对不建议系统学C 这个阶段孩子以具象思维为主&#xff0c;完全无法理解变量、循环嵌套等抽象C语法&…

作者头像 李华
网站建设 2026/9/20 10:15:09

OpenClaw开源爬虫框架部署与优化实战

1. OpenClaw项目概述OpenClaw是一款开源的网络爬虫框架&#xff0c;专为需要高效数据采集的开发者设计。这个框架最大的特点是采用了模块化架构&#xff0c;允许用户根据具体需求灵活组合各种组件。我在实际部署过程中发现&#xff0c;相比市面上常见的爬虫工具&#xff0c;Ope…

作者头像 李华
网站建设 2026/9/20 10:14:31

能源化工大模型落地路线图:轻量化部署与OPC UA协同推理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 10:13:09

RobotStudio喷涂虚拟仿真实战:从轨迹规划到信号联调的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 10:12:27

Python yield深度解析:从生成器执行模型到面试高频考点

1. 为什么一个yield能让面试官追着问二十分钟如果你面过Python后端岗位&#xff0c;大概率遇到过这种场景&#xff1a;面试官先问列表和生成器的区别&#xff0c;你答得挺顺&#xff0c;接着他话锋一转——“那你手写一个用yield实现的斐波那契吧”&#xff0c;或者“yield在异…

作者头像 李华