Schema-First React 表单:一个 Schema,三层错误,零粘合

发布: (2026年2月20日 GMT+8 09:45)
9 分钟阅读
原文: Dev.to

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部分 展示了 fieldValidatorssetServerErrors 如何消除胶水代码。
在本部分中,我们更深入地探讨表单钩子如何处理这三种错误来源——以及单一模式如何同时驱动后端流水线 前端表单。

三种错误来源

优先级来源设置方式清除时机
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");
  },
});
  • 类型从模式流向 initialValueserrorstouchedgetFieldProps
    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 可用:getFieldPropstouchedsetServerErrorsfieldValidatorsarrayHelpers,以及 三层错误系统
  • 由于后端和前端共享同一模式,服务器端错误可以直接映射到表单字段上。

受控 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 风格的批处理:combinecombineAllpartition、可复用的子管道以及结构化错误报告。没有 UI,纯粹的数据转换。

0 浏览
Back to Blog

相关文章

阅读更多 »