Schema-First React 表单:一个 Schema,三层错误,零粘合
I’m sorry, but I can’t retrieve the content from that external link. If you paste the text you’d like translated here, I’ll be happy to translate it into Simplified Chinese while preserving the formatting.
第3部分 – Railway‑Oriented TypeScript
第1部分 展示了 fieldValidators 和 setServerErrors 如何消除胶水代码。
在本部分中,我们更深入地探讨表单钩子如何处理这三种错误来源——以及单一模式如何同时驱动后端流水线 和 前端表单。
三种错误来源
| 优先级 | 来源 | 设置方式 | 清除时机 |
|---|---|---|---|
| 1(最低) | 模式验证 | 在 change / blur / submit 时自动触发 | 每次验证运行时 |
| 2 | 异步字段验证器 | fieldValidators 选项 | 当字段验证器重新运行时 |
| 3(最高) | 服务器错误 | form.setServerErrors(...) | 当用户编辑受影响的字段时 |
优先级更高者获胜。
即使模式验证通过,服务器错误仍然可见——服务器拥有最终权威。
异步的“用户名已被占用”会覆盖模式层面的“太短”——实时检查更为具体。
编辑字段会清除服务器错误,并让模式验证重新生效。
你永远不需要在组件代码中管理这些;只需读取 form.errors.email 并显示即可。
{form.touched.email && form.errors.email && (
{form.errors.email}
)}
{/* Could be a schema error, async field error, or server error.
Always shows the highest‑priority one. */}
安装
npm install @railway-ts/use-form @railway-ts/pipelines
一个模式,两个世界
该模式同时驱动 前端表单 和 后端管道——在下面的全栈章节中会详细介绍。只需定义一次并从共享文件中导出。
// schema.ts
import {
object,
required,
optional,
chain,
string,
nonEmpty,
email,
minLength,
parseNumber,
min,
max,
array,
stringEnum,
refineAt,
type InferSchemaType,
} from "@railway-ts/pipelines/schema";
export const registrationSchema = chain(
object({
username: required(
chain(string(), nonEmpty("Username is required"), minLength(3)),
),
email: required(chain(string(), nonEmpty("Email is required"), email())),
password: required(
chain(string(), nonEmpty("Password is required"), minLength(8)),
),
confirmPassword: required(
chain(string(), nonEmpty("Please confirm your password")),
),
age: required(
chain(parseNumber(), min(18, "Must be at least 18"), max(120)),
),
contacts: optional(array(stringEnum(["email", "phone", "sms"]))),
}),
refineAt(
"confirmPassword",
(d) => d.password === d.confirmPassword,
"Passwords must match",
),
);
export type Registration = InferSchemaType;
使用 useForm 与模式
import { useForm } from "@railway-ts/use-form";
import { registrationSchema, type Registration } from "./schema";
const form = useForm(registrationSchema, {
initialValues: {
username: "",
email: "",
password: "",
confirmPassword: "",
age: 0,
contacts: [],
},
fieldValidators: {
username: async (value) => {
const { available } = await fetch(
`/api/check-username?u=${encodeURIComponent(value)}`,
).then((r) => r.json());
return available ? undefined : "Username is already taken";
},
},
onSubmit: async (values) => {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) form.setServerErrors(await res.json());
else navigate("/welcome");
},
});
- 类型从模式流向
initialValues、errors、touched和getFieldProps。
form.getFieldProps("usernam")会因为字段名不存在而触发 TypeScript 错误。 fieldValidators键的类型仅接受Registration中的 有效字段名。
服务器端错误
setServerErrors 处理字段级错误。对于 不 与特定字段关联的错误(例如网络故障、速率限制),请使用保留的根键:
import { ROOT_ERROR_KEY } from "@railway-ts/pipelines/schema";
form.setServerErrors({
[ROOT_ERROR_KEY]: "Network error. Please try again.",
});
{form.errors[ROOT_ERROR_KEY] && (
{form.errors[ROOT_ERROR_KEY]}
)}
ROOT_ERROR_KEY 是字符串 "_root",并以常量形式导出,这样你就不必在组件中到处散布字面字符串。
常用 UI 模式
复选框组(静态选项 → 数组字段)
{["email", "phone", "sms"].map((option) => (
{option}
))}
动态列表(运行时添加 / 删除)
const {
push,
remove,
insert,
swap,
replace,
} = form.arrayHelpers("todos");
所有操作都是 类型安全 的。错误路径会自动生成——例如,如果 todos[2].text 验证失败,form.errors["todos.2.text"] 中会包含相应的错误信息。你永远不需要手动构造错误路径字符串。
全栈收益
相同的 registrationSchema 驱动前端表单,同时用于验证后端请求,且管道生成的错误格式完全符合 setServerErrors 的预期。
// server.ts
import { validate, formatErrors } from "@railway-ts/pipelines/schema";
import { pipeAsync } from "@railway-ts/pipelines/composition";
import { ok, err, flatMapWith, match } from "@railway-ts/pipelines/result";
import { registrationSchema } from "./schema";
export const register = pipeAsync(
async (req) => {
const body = await req.json();
return validate(registrationSchema, body);
},
flatMapWith((data) => {
// …business logic, e.g. create user in DB
return ok(data);
}),
match({
Ok: (data) => new Response(JSON.stringify(data), { status: 200 }),
Err: (e) =>
new Response(JSON.stringify(formatErrors(e)), { status: 400 }),
}),
);
现在 前端 与 后端 共享同一套验证、错误格式化以及 TypeScript 类型的唯一真相——这正是 Railway‑Oriented TypeScript 的精髓。
Backend – Registration Flow
import { registrationSchema, type Registration } from "./schema"; // same file
const checkEmailUnique = async (data: Registration) => {
const exists = await db.user.findUnique({ where: { email: data.email } });
return exists
? err([{ path: ["email"], message: "Email already registered" }])
: ok(data);
};
const createUser = async (data: Registration) => {
const user = await db.user.create({
data: {
username: data.username,
email: data.email,
password: await hash(data.password),
age: data.age,
},
});
return ok(user);
};
const handleRegistration = async (body: unknown) => {
const result = await pipeAsync(
validate(body, registrationSchema), // Result
flatMapWith(checkEmailUnique), // runs only if validation succeeded
flatMapWith(createUser), // runs only if email is unique
);
return match(result, {
ok: (user) => ({ status: 201, body: { id: user.id } }),
err: (errors) => ({ status: 422, body: formatErrors(errors) }),
});
};
app.post("/api/register", async (req, res) => {
const { status, body } = await handleRegistration(req.body);
res.status(status).json(body);
});
validate(body, registrationSchema)返回Result。- 如果验证通过,
checkEmailUnique将会执行。 - 如果邮箱唯一,
createUser将会执行。 match在最后 只 分支一次,将Result转换为 HTTP 响应。
Error formatting
// ValidationError[] from validate() or checkEmailUnique
[
{ path: ["email"], message: "Email already registered" }
]
// formatErrors() → Record
{
email: "Email already registered"
}
formatErrors 生成的结构正是前端 form.setServerErrors() 所期待的形式,因此无需额外的转换或字段名映射。
前端 – @railway-ts/use-form Hook
import { z } from "zod";
import { useForm } from "@railway-ts/use-form";
const zodSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
age: z.coerce.number().min(18, "Must be at least 18"),
});
type ZodUser = z.infer;
const form = useForm(zodSchema, {
initialValues: { username: "", email: "", password: "", age: 0 },
onSubmit: (values) => console.log(values),
});
- 无需 resolver – 该 Hook 会自动检测 Zod(或 Valibot)模式。
- 完整的 Hook API 可用:
getFieldProps、touched、setServerErrors、fieldValidators、arrayHelpers,以及 三层错误系统。 - 由于后端和前端共享同一模式,服务器端错误可以直接映射到表单字段上。
受控 vs. 非受控 输入
| 方法 | 特点 |
|---|---|
@railway-ts/use-form(受控) | 每一次键入都会更新 React 状态 → 组件重新渲染。模型更直观,状态可见性完整,调试更容易。 |
| React‑Hook‑Form(非受控) | 输入由 ref 支持;DOM 更新不经过 React 重新渲染。对非常大或高度交互的表单更快。 |
| 功能 | 非受控 | 受控 |
|---|---|---|
| 重新渲染策略 | ✅ | ❌ |
| DevTools | ✅ | ❌ |
| 社区规模 | 大 | 小 |
† 大小来源于 bundlephobia(gzip)。
†† @railway-ts 大小来源于 size‑limit;约 7.8 kB brotli。
如果你的项目已经使用 Zod,则 RHF + resolver 的额外成本约为 22.5 kB。@railway-ts 的总大小已包含表单 Hook 以及 完整的管道/验证库——如果你已经在后端使用它,表单 Hook 只会额外增加约 4.8 kB gzip。
演示与资源
- StackBlitz 演示 – 一个完整的注册表单,具备模式验证、跨字段规则、异步用户名检查、服务器错误、复选框组和加载状态。
- GitHub
@railway-ts/pipelines– 模式、Result类型、管道。@railway-ts/use-form– React 表单 Hook。
附加 – 数据处理管道
相同的管道库可在 React 之外用于 ETL 风格的批处理:combine、combineAll、partition、可复用的子管道以及结构化错误报告。没有 UI,纯粹的数据转换。