Effect Schema 怎么和 TanStack Form 配合做表单?
表单里的邮箱规则写在前端校验函数里,后端又写一遍;提交按钮的 loading 来自表单库,请求的 pending 来自另一个布尔值。规则和状态各有两份,迟早漂移。
做法是三个所有者各管一件事:Schema 定义校验规则,只写一次;表单库持有输入过程;提交之后的应用逻辑交给 Atom 里的 Effect。表单库通过 Standard Schema 消费同一个 Schema,提交的 Effect 再解码一次当作安全门。
这条分工来自一个原则:每一个事实只有一个所有者。Atom 是什么、Atom.fn 怎么写,见 用 Effect 写 React 前端,状态该怎么管?;Schema 是什么,见 TypeScript 类型保护不了运行时数据,该怎么校验?。下面用 TanStack Form 做示例,因为它支持 Standard Schema,我不为所有前端指定表单库。
表单里每个事实该归谁?
- 表单库持有输入过程:原始字段值、touched、是否正在提交、重置语义。
- Schema 持有领域校验:什么算合法的邮箱,密码至少几位。只在一个地方定义。
- Effect 持有提交之后的应用逻辑:调接口、取消、并发、依赖。
- React 事件回调只是适配器,不拥有任何业务逻辑,也不该在回调里构造运行时。
数据流只有一个方向:UI 事件,进入表单状态机,经过 Schema 解码,进入应用 Effect。校验失败是留在表单投影里的数据,不是异常。
Schema 怎么交给表单库?
先定义 Schema,并用 Schema.toStandardSchemaV1 导出给表单库用:
// signup-schema.ts
import { Schema } from "effect"
export const Signup = Schema.Struct({
email: Schema.String.check(Schema.isPattern(/^[^@\s]+@[^@\s]+$/, { message: "invalid email" })),
password: Schema.String.check(Schema.isMinLength(8, { message: "at least 8 characters" })),
age: Schema.NumberFromString.check(Schema.isBetween({ minimum: 18, maximum: 120 }, { message: "18-120" }))
})
export type Signup = typeof Signup.Type
export const SignupStandard = Schema.toStandardSchemaV1(Signup)注意 age 的设计:表单里的输入永远是字符串,NumberFromString 负责把它解码成数字。这就是 Schema 的 Encoded 与 Type 之分:表单处理的是 Encoded 一侧,应用拿到的是 Type 一侧。
我直接调用 ["~standard"].validate 验证过:输入 { email: "x", password: "123", age: "12" } 得到三条问题,路径分别是 email、password、age;合法输入则返回 age 已经是数字 30 的值。
提交时为什么还要再解码一次?
因为表单库的校验是给用户看的体验层,解码才是进入应用的安全门。提交的 Effect 在同一个错误通道里同时处理「解码失败」和「接口失败」:
// signup-flow.ts
import { Context, Data, Effect, Layer, Schema } from "effect"
import { Atom } from "effect/reactivity"
import { Signup } from "./signup-schema.ts"
export class EmailTaken extends Data.TaggedError("EmailTaken")<{ readonly email: string }> {}
export class AccountApi extends Context.Service<AccountApi, {
readonly register: (input: Signup) => Effect.Effect<{ readonly id: string }, EmailTaken>
}>()("app/AccountApi") {
static readonly fake = Layer.succeed(AccountApi, {
register: (input) =>
Effect.sleep("30 millis").pipe(
Effect.andThen(
input.email === "taken@example.com"
? Effect.fail(new EmailTaken({ email: input.email }))
: Effect.succeed({ id: `u_${input.age}` })
)
)
})
}
const runtime = Atom.runtime(AccountApi.fake)
export const submitSignup = runtime.fn<unknown>()((raw) =>
Effect.gen(function* () {
const input = yield* Schema.decodeUnknownEffect(Signup)(raw)
const api = yield* AccountApi
return yield* api.register(input)
})
)入参是 unknown,这一点很关键:提交函数不信任调用者,自己再解码一次。两处用同一个 Schema,所以规则不会漂移。不要在表单回调里再手写一遍校验,规则只在 Schema 里定义。
提交状态怎么只留一个所有者?
用 useAtom 的 mode: "promiseExit"。组件如下:
// signup-form.tsx
import { useForm } from "@tanstack/react-form"
import { useAtom } from "@effect/atom-react"
import { AsyncResult } from "effect/reactivity"
import { SignupStandard } from "./signup-schema.ts"
import { submitSignup } from "./signup-flow.ts"
export function SignupForm() {
const [result, submit] = useAtom(submitSignup, { mode: "promiseExit" })
const form = useForm({
defaultValues: { email: "", password: "", age: "" },
validators: { onChange: SignupStandard },
onSubmit: async ({ value }) => {
await submit(value)
}
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
void form.handleSubmit()
}}
>
<form.Field name="email">
{(field) => (
<div>
<input
id="email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
<span className="err">
{field.state.meta.errors.map((issue) => issue?.message).join(",")}
</span>
</div>
)}
</form.Field>
<form.Subscribe selector={(s) => s.isSubmitting}>
{(pending) => <button type="submit" disabled={pending}>{pending ? "..." : "sign up"}</button>}
</form.Subscribe>
<output>
{AsyncResult.matchWithError(result, {
onInitial: () => "idle",
onError: (e) => (e._tag === "EmailTaken" ? `taken: ${e.email}` : "invalid input"),
onDefect: () => "unexpected error",
onSuccess: (s) => `ok ${s.value.id}`
})}
</output>
</form>
)
}示例里省略了 password 和 age 两个字段,写法和 email 完全相同。我在 jsdom 里补全三个字段后跑了一遍,行为是:
- 输入非法邮箱时,错误信息
invalid email出现,来自 Schema 的 message。 - 字段不全时点击提交,不会调用提交 atom,输出仍是
idle。 - 提交已占用的邮箱,输出
taken: taken@example.com,这是EmailTaken经过matchWithError得到的。 - 提交过程中,按钮显示
...;换成合法邮箱后,输出ok u_30。
默认的 setter 是即发即忘的,调用返回 void,表单库不知道提交什么时候结束,isSubmitting 会立刻变回 false。用 promiseExit 后,setter 返回一个 Promise,表单库的 onSubmit 可以 await 它,于是**「是否正在提交」只有表单一个所有者**,不需要读 atom 的 waiting,也不需要额外维护 loading 布尔值。提交的结果(成功、预期错误、defect)属于应用层,由 atom 持有并用 matchWithError 渲染。
另一个要避免的做法是把表单状态镜像进 atom 或 useState:输入过程属于表单库,镜像一份就要写同步代码。
版本是 effect@4.0.5 与 @effect/atom-react@4.0.5,表单库是 TanStack Form。["~standard"].validate的结果是直接调用得到的;表单组件的行为是在 jsdom 里补全三个字段后跑过的,类型在 strict 模式下通过。文中展示的signup-form.tsx省略了两个字段,我没有逐字运行这份精简版。