- 后端
- 前端
【免费下载链接】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.
表单是 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持有,value与onChange显式绑定,没有隐式的表单上下文; - 校验在两层进行:服务端 tRPC procedure 的 Zod input schema 是最终防线,前端 Zod schema 共享用于搜索参数清洗或即时检查;
- 提交通过 TanStack Query 的 mutation hook 完成,错误、加载态都由 mutation 对象直接暴露。
从仓库看,apps/app/routes/(app)/members.tsx中的CreateOrganizationCard与apps/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> ); }这个例子里有几个值得细读的设计细节:
useId关联 Label 与 Input:nameId由 React 的useId()生成,避免硬编码 id 造成的多实例冲突;- 提交成功后的清空输入属于表单职责:代码注释点明——清空输入是"这个表单"自己的行为,而不是每个调用方都应共享的副作用,所以挂在
mutate调用的onSuccess回调上,而不是 query 模块的onSuccess上。这与后文"模块拥有缓存失效、调用方拥有导航"的职责划分一脉相承; aria-invalid+role="alert":服务端校验失败发生在 submit 之后,必须通过role="alert"让屏幕阅读器主动播报——这是可访问性上的硬性要求。
该模式在members.tsx的CreateOrganizationCard(apps/app/routes/(app)/members.tsx/members.tsx#L106-L163))中有完全一致的真实实现:aria-invalid、aria-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.ts中useCreateOrganization更进一步展示了 mutation 的边界处理:onSuccess里调用revalidateSession(queryClient, router),因为"活跃组织"存在 session 行上,必须先刷新 session 缓存,组织域查询才能看到新组织;且onSuccess是returned(返回而非 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避免过期闭包:transitionTo是useCallback记忆化的,通过 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_ATTEMPTS、OTP_EXPIRED、INVALID_OTP)。
四个关键设计决策
useAuthForm注释中明确了几个刻意的设计选择:
- Counter-based pending ops:用计数器而非布尔值跟踪子操作,正确处理重叠的子操作(如快速双击导致的并发请求)。
setChildBusy用setPendingOps((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.tsx的CreateOrganizationCard在此基础上加了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} />;这里有两个要点:
revalidateSession先于导航:它removeQueries({ queryKey: sessionQueryKey })清掉 session 缓存,再router.invalidate()重跑路由守卫(见 apps/app/lib/queries/session.ts)。注释指出这里用removeQueries而非invalidateQueries,是为了让beforeLoad看到undefined重新拉取,而不是复用旧的 session;returnTo来自已验证的搜索参数:search.returnTo已经过前文 Zod schema 清洗,可直接拼入导航目标。
因为调用方决定成功后的行为,AuthForm同时支撑登录页和注册页两个路由(mode="login" | "signup"仅影响文案与 Passkey 可用性,两者走同一套 OTP 流程)——这就是职责分离带来的直接复用收益。
模式小结与适用边界
回顾整套表单实践,可以提炼出几条可复用的规则:
| 关注点 | 归属 | 依据 |
|---|---|---|
| 输入状态 | 表单组件useState | 受控输入基本模式 |
| 服务端校验 | tRPC procedure 的 Zod input | docs/api/validation-errors.md |
| 前端即时校验/清洗 | 共享 Zod schema +validateSearch | login 路由的returnTo清洗 |
| 提交逻辑与缓存失效 | query 模块的 mutation hook | apps/app/lib/queries/organization.ts |
| 成功后的导航 | 调用方onSuccess | login 页的handleSuccess |
| 错误播报 | role="alert"盒子 | 所有表单统一模式 |
| 防重复提交 | 统一isDisabled/ mutationisPending | useAuthForm、OtpVerification |
这套方案的适用前提是表单形态相对直接:单步提交、字段数量有限、校验集中在提交点。当表单出现复杂的字段联动、动态校验、条件显隐时,再评估是否需要引入表单库;而像登录/注册这样的多步骤流程,仓库展示的是用显式状态机 + 受控组件替代表单库的做法——状态迁移表、成功守卫、计数器式 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.
相关推荐
ReactPy中的表单状态管理模式:受控与非受控组件
ReactPy中的表单状态管理模式:受控与非受控组件 在Web开发中,表单交互是用户体验的核心环节。作为Python开发者,你是否曾因表单状态同步问题而困扰?当
前端UI组件react-redux-starter-kit中的表单处理:从受控组件到Formik集成
react redux starter kit中的表单处理:从受控组件到Formik集成 在现代前端开发中,表单处理是构建用户交互界面的核心环节。无论是简单的登
前端示例工程Darner vs Redis vs RabbitMQ:为什么选择轻量级持久化消息队列?终极指南
Darner vs Redis vs RabbitMQ:为什么选择轻量级持久化消息队列?终极指南 在当今微服务架构和分布式系统盛行的时代,消息队列已成为系统解耦
消息队列
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考