无需 Glue Code 的类型安全 React 表单
I’m happy to translate the article for you, but I need the actual text you’d like translated. Could you please paste the content (or the portion you want translated) here? I’ll keep the source line, formatting, markdown, and any code blocks exactly as you specify.
每个 React 表单库都让你拼装相同的谜题
一个验证库、一个适配器包、一个独立的 TypeScript 接口,以及表单 Hook 本身。每个部分单独来看都不错。摩擦点在于它们的连接。
我想看看当模式、类型和表单 Hook 从一开始就一起设计时会怎样。没有适配器。没有解析器。只有一条依赖链,类型从你的模式定义一路流向字段属性的自动补全。
这就是我构建的内容,以及它与我之前做法的对比。
常规设置
下面是一个典型的 React Hook Form + Zod 注册表单。这段代码写得很好——我已经写了多年类似的表单:
// 1. Install three packages
// npm add react-hook-form zod @hookform/resolvers/zod
// 2. Define a Zod schema
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
age: z.coerce.number().min(18, 'Must be 18+'),
bio: z.string().optional(),
})
// 3. Extract the type
type FormValues = z.infer
// 4. Wire everything together with the resolver adapter
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '', age: 0, bio: '' },
})
const onSubmit = async (values: FormValues) => {
await api.register(values)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('age')} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit" disabled={isSubmitting}>Submit</button>
</form>
)
}
三个包。一个解析器适配器,将 Zod 的输出格式桥接到 React Hook Form 的错误格式。一个单独的类型提取步骤。它可以工作,且 RHF 是一个构建良好的库,但每个表单都要从这套仪式开始。
单一模式,无需胶水
下面展示了使用一个将 schema、类型推断和表单钩子组合在一起的库来实现相同表单的方式:
// 1. Install two packages (the form hook and its validation dependency)
// npm add @railway-ts/use-form @railway-ts/pipelines
// 2. Define a schema — this IS the validator AND the type source
import { useForm } from '@railway-ts/use-form'
import {
object,
required,
optional,
chain,
string,
nonEmpty,
email,
parseNumber,
min,
type InferSchemaType,
} from '@railway-ts/pipelines/schema'
const schema = object({
name: required(chain(string(), nonEmpty('Name is required'))),
email: required(chain(string(), nonEmpty('Email is required'), email('Invalid email'))),
age: required(chain(parseNumber(), min(18, 'Must be 18+'))),
bio: optional(string()),
})
// 3. That’s it — type is inferred, hook consumes the schema directly
type FormValues = InferSchemaType
// { name: string; email: string; age: number; bio?: string }
function RegistrationForm() {
const form = useForm(schema, {
initialValues: { name: '', email: '', age: 0, bio: '' },
onSubmit: async (values) => {
// `values` is typed as FormValues — guaranteed valid
await api.register(values)
},
})
return (
<form onSubmit={form.handleSubmit}>
<input {...form.getFieldProps('name')} />
{form.touched.name && form.errors.name && <span>{form.errors.name}</span>}
<input {...form.getFieldProps('email')} />
{form.touched.email && form.errors.email && <span>{form.errors.email}</span>}
<input {...form.getFieldProps('age')} />
{form.touched.age && form.errors.age && <span>{form.errors.age}</span>}
<button type="submit">Submit</button>
</form>
)
}
没有 resolver,也没有适配器。schema 直接传入 useForm,类型会自动流动。
当你键入 form.getFieldProps(' 时,编辑器会自动补全 name、email、age、bio。如果键入 form.getFieldProps('nme'),TypeScript 会在编译时捕获错误。form.errors.email 已经有类型,form.values.age 是数字。这一切都来源于同一个 schema 定义。
不仅仅是文本输入
这个 hook 为原生 HTML 表单元素提供绑定:
{/* Text, email, password, textarea */}
<input {...form.getFieldProps('email')} type="email" />
{/* Select */}
<select {...form.getFieldProps('country')}>
<option value="">Choose…</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
{/* Checkbox (boolean) */}
<input {...form.getFieldProps('agreeTerms')} type="checkbox" />
{/* Switch (toggle — styled checkbox) */}
<input {...form.getFieldProps('notifications')} type="checkbox" />
{/* Radio group */}
<input {...form.getFieldProps('plan')} type="radio" value="free" /> Free
<input {...form.getFieldProps('plan')} type="radio" value="pro" /> Pro
{/* Checkbox group (array of values) */}
<input {...form.getFieldProps('interests[0]')} type="checkbox" value="sports" /> Sports
<input {...form.getFieldProps('interests[1]')} type="checkbox" value="music" /> Music
{/* File input */}
<input {...form.getFieldProps('avatar')} type="file" />
{/* Range slider */}
<input {...form.getFieldProps('volume')} type="range" min="0" max="100" />
每个帮助函数都会为相应的元素类型返回正确的 id、name、value/checked 和 onChange 属性,使 API 既人性化又类型安全。
嵌套对象 — 只需使用点
没有针对嵌套数据的特殊 API。点表示法在任何地方都适用:
import {
object,
required,
chain,
string,
nonEmpty,
} from '@railway-ts/pipelines/schema'
const profileSchema = object({
name: required(string()),
address: required(
object({
street: required(string()),
city: required(
chain(string(), nonEmpty('City is required'))
),
zip: required(string()),
})
),
})
// In the form:
{form.touched['address.city'] && form.errors['address.city'] && (
<span>{form.errors['address.city']}</span>
)}
自动完成在嵌套时同样有效 — 输入 address.,编辑器会建议 street、city、zip。
动态数组
arrayHelpers 为您提供针对列表的类型化变更方法:
const { values, push, remove, swap, getFieldProps } =
form.arrayHelpers('contacts')
{values.map((contact, i) => (
<div key={i}>
<input {...getFieldProps(`contacts[${i}].name`)} />
<input {...getFieldProps(`contacts[${i}].email`)} />
<button type="button" onClick={() => remove(i)}>Remove</button>
</div>
))}
<button type="button" onClick={() => push({ name: '', email: '' })}>
Add Contact
</button>
push、remove、insert、swap、move、replace —— 全部类型安全,并自动更新验证。
验证模式
并非每个表单都需要相同的验证时机:
// Validate on every keystroke and blur (default)
useForm(schema, { initialValues, validationMode: 'live' })
// Validate only when a field loses focus
useForm(schema, { initialValues, validationMode: 'blur' })
// Validate once on mount — good for editing existing records
useForm(schema, { initialValues, validationMode: 'mount' })
// Don't validate until submit
useForm(schema, { initialValues, validationMode: 'submit' })
服务器错误
提交后,您的 API 可能返回字段级别的错误。设置它们后,当用户编辑该字段时会自动清除:
const form = useForm(schema, {
initialValues: { email: '', username: '' },
onSubmit: async (values) => {
const response = await api.register(values)
if (!response.ok) {
form.setServerErrors({
email: 'Email already exists',
username: 'Username taken',
})
return
}
router.push('/dashboard')
},
})
服务器错误的优先级高于客户端验证错误。当用户更改 email 字段时,email 的服务器错误会自动清除——无需手动清理。
每字段异步验证
Some fields need their own async check — e.g., “is this username available?” — independent of the schema:
const form = useForm(schema, {
initialValues: { username: '', email: '' },
fieldValidators: {
username: async (value) => {
const taken = await api.checkUsername(value)
return taken ? 'Username is already taken' : undefined
},
},
})
// Show loading state while checking
{form.validatingFields.username && <span>Checking...</span>}
字段验证器仅在该字段的模式验证通过后才会运行。它们的错误会单独存储,因而在模式重新验证时不会被覆盖。
已经在使用 Zod 或 Valibot?
该 Hook 接受任何 Standard Schema v1 验证器。如果你已经在使用 Zod 或 Valibot,可以直接使用它们——无需适配器:
import { z } from 'zod'
import { useForm } from '@railway-ts/use-form'
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
type FormValues = z.infer
// 直接使用 — 无需 resolver,也无需 adapter
const form = useForm(schema, {
initialValues: { email: '', password: '' },
})
相同的 Hook、相同的类型化字段属性、相同的一切。Standard Schema 协议意味着该 Hook 并不在乎是哪一个验证库生成了 schema。
它的构建基础
表单钩子是一个名为 @railway-ts 的小型生态系统的一部分。验证由一个使用 Result 类型的函数式管道库提供支持——值要么是 Ok(有效),要么是 Err(错误列表)。错误会在一次遍历中跨所有字段累积,而不是在首次失败时短路。
使用表单钩子时不需要了解这些细节,但如果你想要可组合的验证管道、类型化的错误处理,或使用 pipe/flow 来链式操作,这些组件已经准备好。
- 表单钩子大小:约 3.6 kB
- 完整管道库大小:约 4.2 kB(均可树摇)
试一试
npm add @railway-ts/use-form @railway-ts/pipelines
- GitHub
- Getting Started — 从第一个表单到数组的逐步指南
- Live Demo on StackBlitz
- Recipes — Material UI、Chakra UI、测试模式、性能技巧
兼容 React 18 和 19。MIT 许可证。