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 省略了两个字段,我没有逐字运行这份精简版。