Effect 前端实践:用 Atom 管理状态,让表单只有一个所有者
前面几篇讲的都是服务端风格的问题:错误、依赖、并发、运行时边界。这一篇换到浏览器里,问题其实是同一个:每一个事实应该只有一个所有者。
前端的混乱几乎都来自违反这条。同一份服务端数据,一份在 useState,一份在请求库的缓存,还有一份在表单里;同一次提交,按钮的 loading 是一个布尔值,表单的 submitting 是另一个。状态一旦有两份,同步它们的代码就成了 bug 的来源。
这一篇用三件事把所有权讲清楚:Atom 持有应用状态与异步数据,Schema 持有校验规则,表单库持有输入过程。代码使用 effect@4.0.5 与 @effect/atom-react@4.0.5,在 React 19 里实际渲染、点击、提交过,类型在 strict 模式下通过。
先把包的事实说清楚
我自己的旧资料里写的是 @effect-atom/atom-react,那是社区在 v3 时代的包。我查了 npm:它的最新版是 0.7.0,peer 依赖是 effect ^3.22.1,所以它面向 v3,不是 v4 的答案。我没有在 4.0.5 上验证它,也就不在文章里使用它。
v4 的做法是把 Atom 收进核心包,作为 effect/reactivity 子路径导出;React 绑定是独立的 @effect/atom-react,版本号与核心同步,peer 依赖是 React 19 和 effect ^4.0.5。
npm install effect@4.0.5 @effect/atom-react@4.0.5 react@19另外有几处 v3 写法在这里已经不存在:
- 异步状态类型叫
AsyncResult,不再是Result。Result在 v4 是Either的新名字,和 UI 状态是两回事。 - 带写入副作用的 atom 用
Atom.fn,没有Atom.writable(effectRead, effectWrite)这种用法。 Atom.runtime(layer)返回的运行时上有.atom、.fn,依赖从 Layer 注入。Atom.kvs的defaultValue是一个返回默认值的函数,不是裸值。
Atom 的心智模型
Atom 是一个响应式状态容器。最基础的形态有三种:可写的原始状态、从其他 atom 派生的状态、按参数缓存的一族 atom。
// atoms.ts
import { Atom } from "effect/reactivity"
export const count = Atom.make(0)
export const doubled = Atom.make((get) => get(count) * 2)
export const userLabel = Atom.family((id: string) => Atom.make(`user:${id}`))
export const sticky = Atom.keepAlive(Atom.make(0))我在一个独立的 AtomRegistry 里跑过这几个 atom,得到几条值得记住的事实:
userLabel("a") === userLabel("a")为 true,同一个参数永远得到同一个 atom;不同参数得到不同的 atom。- 派生 atom 即使没人订阅,也能直接读出当前值:
count设为 5 之后,读doubled得到 10。 - 没人订阅的 atom 会被回收,状态回到初始值。 我把空闲 TTL 调短后验证:
count设为 5,等待之后再读,已经回到 0。 - 用
Atom.keepAlive包裹的 atom 不会被回收,sticky设为 3 之后仍然是 3。
最后一条是新手最容易踩的坑:把「需要跨页面保留的状态」写成普通 atom,切走页面后它就悄悄重置了。判断标准是这份状态的生命周期是否长于使用它的组件。长于,就 keepAlive;需要跨刷新存活,就走持久化(下面会讲)。
异步数据:Layer 注入,AsyncResult 表达三态
Effect 驱动的 atom 不返回裸值,而是 AsyncResult<A, E>,三种形态:Initial、Success、Failure,并带一个 waiting 标志位表示正在重新请求。
依赖怎么进来?不是在 atom 里写 fetch,而是和服务端一样:用 Context.Service 定义能力,用 Layer 提供实现,再用 Atom.runtime(layer) 把 Layer 绑成运行时。
// todos.ts
import { Context, Data, Effect, Layer } from "effect"
import { Atom } from "effect/reactivity"
export class TodoError extends Data.TaggedError("TodoError")<{ readonly reason: string }> {}
export interface Todo {
readonly id: number
readonly title: string
}
export class TodoApi extends Context.Service<TodoApi, {
readonly list: Effect.Effect<ReadonlyArray<Todo>, TodoError>
readonly add: (title: string) => Effect.Effect<Todo, TodoError>
}>()("app/TodoApi") {
static readonly memory = Layer.sync(TodoApi, () => {
const items: Array<Todo> = []
return {
list: Effect.sync(() => [...items]),
add: (title: string) =>
title.trim() === ""
? Effect.fail(new TodoError({ reason: "empty title" }))
: Effect.sync(() => {
const todo = { id: items.length + 1, title }
items.push(todo)
return todo
})
}
})
}
export const runtime = Atom.runtime(TodoApi.memory)
export const todosAtom = runtime.atom(TodoApi.use((api) => api.list)).pipe(
Atom.runtime.withReactivity(["todos"])
)
export const addTodo = runtime.fn<string>()(
(title) => TodoApi.use((api) => api.add(title)),
{ reactivityKeys: ["todos"] }
)读与写之间靠 reactivity key 连接。todosAtom 声明自己依赖 "todos" 这个 key,addTodo 声明自己成功后失效 "todos"。我实测的行为是:调用 addTodo 之后,已挂载的 todosAtom 自动重新拉取,列表从空变成包含新条目;调用 addTodo 传入空标题则落到 Failure,错误值是 TodoError,列表保持原样不被破坏。
这就是「写入后失效重拉」的完整机制,没有手写的 refetch(),也没有在 mutation 回调里去改缓存。数据只有一个来源,就是服务,atom 只是它的投影。
组件里怎么用
组件只负责把 AsyncResult 映射成界面。我推荐用 matchWithError,因为它把失败拆成「预期错误」和「defect」两条,和 typed errors 那一篇的区分完全对应:
// app.tsx
import { AsyncResult } from "effect/reactivity"
import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
import { addTodo, todosAtom } from "./todos.ts"
export function TodoList() {
const result = useAtomValue(todosAtom)
return AsyncResult.matchWithError(result, {
onInitial: () => <p>loading</p>,
onError: (e) => <p role="alert">failed: {e.reason}</p>,
onDefect: () => <p role="alert">unexpected error</p>,
onSuccess: (s) => (
<ul>
{s.value.map((t) => <li key={t.id}>{t.title}</li>)}
{s.waiting ? <li>refreshing</li> : null}
</ul>
)
})
}
export function AddButton() {
const add = useAtomSet(addTodo)
return <button onClick={() => add("from button")}>add</button>
}
export const App = () => (
<RegistryProvider>
<TodoList />
<AddButton />
</RegistryProvider>
)我在 jsdom 里渲染了这棵树:首次渲染得到空列表,点击按钮后,列表里出现 from button,中间没有任何手写的刷新调用。
onError 的参数类型是 TodoError,所以 e.reason 有类型保护,不需要 as。如果后端再加一个错误类型,这里的联合类型会逼你处理它。这是前面「错误是类型」的红利延伸到了 UI。
两个提醒:RegistryProvider 是每棵应用树一个 registry,atom 的状态存在 registry 里,不在 atom 自己身上;useAtomValue 订阅的粒度就是重渲染的粒度,所以按用途拆 atom、拆组件,不要在顶层组件里读一个巨大的 atom。
测试:换 Layer,不碰组件
Layer 注入的真正回报在测试。runtime.layer 本身是一个 atom,所以可以通过 registry 的 initialValues 把它替换成测试用的 Layer,业务 atom 与组件一行都不用改:
// todos-swap.ts
import { Effect, Layer } from "effect"
import { AsyncResult, AtomRegistry } from "effect/reactivity"
import { runtime, TodoApi, TodoError, todosAtom } from "./todos.ts"
const Broken = Layer.succeed(TodoApi, {
list: Effect.fail(new TodoError({ reason: "backend down" })),
add: () => Effect.fail(new TodoError({ reason: "backend down" }))
})
const registry = AtomRegistry.make({ initialValues: [[runtime.layer, Broken]] })
registry.mount(todosAtom)
await new Promise((r) => setTimeout(r, 30))
console.log(
AsyncResult.matchWithError(registry.get(todosAtom), {
onInitial: () => "initial",
onError: (e) => `error: ${e.reason}`,
onDefect: () => "defect",
onSuccess: () => "success"
})
)输出是 error: backend down,说明替换生效了。React 侧对应的是 RegistryProvider 的 initialValues 属性,机制相同。这样「接口挂了界面怎么显示」可以在没有网络、没有 mock 服务器的情况下被稳定测试。
持久化:Atom.kvs,而不是裸 localStorage
需要跨刷新存活的偏好,用 Atom.kvs:它通过 KeyValueStore 服务读写,并用 Schema 做编解码。
// theme.ts
import { Schema } from "effect"
import { Atom } from "effect/reactivity"
import { BrowserKeyValueStore } from "@effect/platform-browser"
const runtime = Atom.runtime(BrowserKeyValueStore.layerLocalStorage)
export const themeAtom = Atom.kvs({
runtime,
key: "ui.theme",
schema: Schema.Literals(["light", "dark"]),
defaultValue: () => "light" as const
})这需要再装一个 @effect/platform-browser@4.0.5。我在 jsdom 的 localStorage 上验证:首次读取是默认值 light,设置为 dark 之后,存储里的原始字符串是 "dark",读回也是 dark。
价值不在于少写几行,而在于边界:存储里读出来的是不可信的外部数据,Schema 保证读回来的一定是 light 或 dark,被手工改坏的值不会渗进应用。存储后端也变成了可替换的 Layer,换成 sessionStorage 或 IndexedDB 只改一行。
表单:一条单向路径
表单是最容易出现多个所有者的地方。我先给出所有权的划分:
- 表单库持有输入过程:原始字段值、touched、是否正在提交、重置语义。
- Schema 持有领域校验:什么算合法的邮箱,密码至少几位。只在一个地方定义。
- Effect 持有提交之后的应用逻辑:调接口、取消、并发、依赖。
- React 事件回调只是适配器,不拥有任何业务逻辑,也不该在回调里构造运行时。
数据流只有一个方向:UI 事件,进入表单状态机,经过 Schema 解码,进入应用 Effect。校验失败是留在表单投影里的数据,不是异常。
我不为所有前端指定表单库,这里用 TanStack Form 做示例,是因为它支持 Standard 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,所以规则不会漂移。
最后是组件:
// 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。
重点是 mode: "promiseExit" 这个选项。默认的 setter 是即发即忘的,调用返回 void,表单库不知道提交什么时候结束,isSubmitting 会立刻变回 false。用 promiseExit 后,setter 返回一个 Promise,表单库的 onSubmit 可以 await 它,于是**「是否正在提交」只有表单一个所有者**,我没有读 atom 的 waiting,也没有额外维护 loading 布尔值。而提交的结果(成功、预期错误、defect)属于应用层,由 atom 持有并用 matchWithError 渲染。每个事实一个所有者,边界就是这条线。
反模式清单
- 在组件里
useState+useEffect拉数据。 这正是 atom 要替换的写法:它既没有取消,也没有依赖注入,还要手工管理 loading 和 error。 - 在组件或回调里直接
Effect.runPromise。 它绕过 registry 与 Layer,副作用既不可测也不可替换;需要触发 Effect,就用Atom.fn加useAtomSet。 - 把表单状态镜像进 atom 或
useState。 输入过程属于表单库,镜像一份就要写同步代码。 - 在表单回调里重新写一遍校验。 校验规则只在 Schema 里定义,表单库通过 Standard Schema 消费它。
- 裸
localStorage。 用Atom.kvs加 Schema,读出的数据才是可信的。 - 把跨页面状态写成普通 atom 而忘记
keepAlive。 无人订阅就会被回收,这是 UI 里偶发「状态丢了」的常见原因。
小结
前端的核心纪律仍然是所有权:
- Atom 持有应用状态与异步数据,服务端数据只有一个来源;写入通过 reactivity key 让读自动失效,不手写 refetch。
- 依赖用
Atom.runtime(layer)注入,测试通过initialValues替换runtime.layer,组件不用改。 - 异步三态用
AsyncResult,渲染用matchWithError,把预期错误与 defect 分开处理。 - Schema 定义一次校验规则:表单库经 Standard Schema 消费它,提交的 Effect 再解码一次作为安全门。
- 表单库持有输入与提交过程,用
promiseExit把提交的结束时刻交还给它;应用层结果由 atom 持有。
还有一个范围声明:我只验证了上述 API 与行为,没有验证 useAtomSuspense、SSR 水合和 AtomHttpApi 等能力,这些我选择不展开,等验证之后再写。