Use Cases (更新: 2026/6/2)

用 Claude Code 设计 Jotai atoms 的实战指南

用 Claude Code 安全设计 Jotai atoms:派生、异步、SSR、测试和提示词一次讲清。

用 Claude Code 设计 Jotai atoms 的实战指南

只让 Claude Code “用 Jotai 做状态管理”,范围太宽。它很可能生成能跑的代码,但 atom 的粒度、服务器数据的边界、Provider 放在哪里、SSR 如何注入初始值、测试如何覆盖,都会变成偶然结果。等到筛选器、表单草稿、异步读取和多语言页面混在一起时,问题才会暴露。

本文把 Jotai atoms 当成 React 小状态的设计工具,而不是万能全局 store。官方 Jotai atom 文档说明,atom config 只是定义,本身不保存值;值存在 store 里。异步部分参考 Jotai async guide 和 React 官方 <Suspense> 文档。Provider 与 SSR 参考 Jotai ProviderSSR utilities

Claude Code 方面,官方 Claude Code overview说明它会读取代码库、编辑文件并运行命令。因此,提示词要给出边界,敏感文件要用 settings 文档中的permissions.deny排除。React 基础可对照Claude Code React 开发指南,服务器状态可对照TanStack Query 指南

先固定 atom 的心智模型

atom 不是值本身,而是一份稳定的定义,告诉 Jotai 如何在 store 中读取或写入值。如果 Claude Code 没有这个前提,它常会创建一个巨大的pageStateAtom,把搜索词、API 响应、已选行、toast 和表单草稿都塞进去。代码看起来集中,但每次修改都牵动整页。

开始写代码前,先回答三个问题。这个值是 UI 状态,还是服务器才是事实来源?它是否需要被相距很远的组件共享?它能否从其他 atom 推导出来,而不是单独保存?搜索词、筛选项、当前 tab、短 toast、多步骤表单草稿适合 Jotai。商品列表、用户表、认证 token、支付 session、库存事实通常应放在服务器状态工具或服务端。

状态适合放进 Jotai应避免
表单草稿多步骤共享的临时输入保存完整订单对象
管理后台筛选表格、计数、URL 同步共同使用保存完整 API 响应
modal/toast远处按钮也会触发长错误日志、审计日志
用户偏好主题、密度、折叠状态token、邮箱、地址、支付信息

Masa 在管理后台踩过的坑是,把“使用 Jotai”当成目标,而不是先做状态盘点。第一版把筛选、已获取行、选中行、保存中状态和 toast 放在同一个 atom。返回列表后,旧选中行还留着,影响了新的批量操作。把 API 数据、选中状态和短命 UI 状态拆开后,Claude Code 的差异更小,review 也更容易。

安装并做一个最小可运行切片

在 Vite 或 Next.js React 项目中,先安装 Jotai。如果新项目需要 atom family,建议使用jotai-family,因为当前 Jotai 文档已经提示jotai/utils里的atomFamily在 Jotai v3 会被废弃。

npm i jotai jotai-family
npm i -D vitest @testing-library/react @testing-library/user-event

下面的任务面板示例包含 primitive atom、derived atom 和 write-only atom。它足够小,可以直接交给 Claude Code 修改,也足够完整,可以放进测试。

import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";

export type TaskStatus = "todo" | "doing" | "done";

export type Task = {
  id: string;
  title: string;
  status: TaskStatus;
};

const createId = () =>
  globalThis.crypto?.randomUUID?.() ?? String(Date.now());

export const tasksAtom = atom<Task[]>([
  { id: "task-1", title: "Write release note", status: "todo" },
]);

export const filterAtom = atom<TaskStatus | "all">("all");
export const draftTitleAtom = atom("");

export const visibleTasksAtom = atom((get) => {
  const filter = get(filterAtom);
  const tasks = get(tasksAtom);
  return filter === "all"
    ? tasks
    : tasks.filter((task) => task.status === filter);
});

export const taskStatsAtom = atom((get) => {
  const tasks = get(tasksAtom);
  return {
    total: tasks.length,
    done: tasks.filter((task) => task.status === "done").length,
  };
});

export const addTaskAtom = atom(null, (get, set) => {
  const title = get(draftTitleAtom).trim();
  if (!title) return;

  set(tasksAtom, (tasks) => [
    ...tasks,
    { id: createId(), title, status: "todo" },
  ]);
  set(draftTitleAtom, "");
});

export const toggleTaskAtom = atom(null, (_get, set, id: string) => {
  set(tasksAtom, (tasks) =>
    tasks.map((task) =>
      task.id === id
        ? { ...task, status: task.status === "done" ? "todo" : "done" }
        : task,
    ),
  );
});

export function TaskBoard() {
  const [draft, setDraft] = useAtom(draftTitleAtom);
  const [filter, setFilter] = useAtom(filterAtom);
  const tasks = useAtomValue(visibleTasksAtom);
  const stats = useAtomValue(taskStatsAtom);
  const addTask = useSetAtom(addTaskAtom);
  const toggleTask = useSetAtom(toggleTaskAtom);

  return (
    <section>
      <p>
        Total: {stats.total} / Done: {stats.done}
      </p>

      <label>
        New task
        <input
          value={draft}
          onChange={(event) => setDraft(event.currentTarget.value)}
        />
      </label>
      <button type="button" onClick={addTask}>
        Add
      </button>

      <select
        value={filter}
        onChange={(event) =>
          setFilter(event.currentTarget.value as TaskStatus | "all")
        }
      >
        <option value="all">All</option>
        <option value="todo">Todo</option>
        <option value="doing">Doing</option>
        <option value="done">Done</option>
      </select>

      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            <span>{task.title}</span>
            <button
              type="button"
              aria-label={`Mark ${task.title} done`}
              onClick={() => toggleTask(task.id)}
            >
              {task.status === "done" ? "Undo" : "Done"}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}

让 Claude Code 生成后,要追问三点:派生值是否重复保存,write-only atom 是否把更新规则集中在一处,组件是否只订阅自己渲染所需的 atom。

用具体用例决定粒度

第一个用例是管理后台筛选栏。searchstatuspagesort可放在 atom 中,因为表格、计数 badge 和 URL 同步都会用到。完整 API 响应不要放进同一个 atom。提示词中应明确:“URL 可见条件和纯 UI 条件分开,API 响应保留在服务器状态层。”

第二个用例是多步骤结账或 onboarding 表单。草稿字段、当前步骤、校验结果适合 Jotai。已提交订单、支付 session、库存检查不适合。成功后用一个 write-only reset atom 清理草稿,比在多个组件里手动 reset 更安全。

第三个用例是详情页 UI 状态。行展开、选中 tab、当前 ID、短 toast 队列可拆成小 atom,只让受影响的组件重新渲染。一个detailPageAtom生成更快,但 review 与性能定位更困难。

第四个用例是用户偏好。主题、显示密度、已关闭提示可用存储工具处理,但隐私数据不要持久化到浏览器。对有变现 CTA 的网站也一样,CTA 展开状态风险低,买家邮箱和优惠券历史应放服务端。

派生 atom 与 write-only atom

派生 atom 从其他 atom 计算值。数量、筛选列表、校验结果通常应派生,不应保存。write-only atom 用来集中动作,例如 patch 表单、reset 草稿、提交前规范化输入。

import { atom } from "jotai";

export type CheckoutDraft = {
  email: string;
  postalCode: string;
  agreed: boolean;
};

const emptyCheckoutDraft: CheckoutDraft = {
  email: "",
  postalCode: "",
  agreed: false,
};

export const checkoutDraftAtom = atom<CheckoutDraft>(emptyCheckoutDraft);

export const checkoutErrorsAtom = atom((get) => {
  const draft = get(checkoutDraftAtom);
  const errors: Partial<Record<keyof CheckoutDraft, string>> = {};

  if (!draft.email.includes("@")) {
    errors.email = "Check the email address";
  }

  if (!/^\d{3}-?\d{4}$/.test(draft.postalCode)) {
    errors.postalCode = "Enter a seven digit postal code";
  }

  if (!draft.agreed) {
    errors.agreed = "Agreement is required";
  }

  return errors;
});

export const patchCheckoutDraftAtom = atom(
  null,
  (_get, set, patch: Partial<CheckoutDraft>) => {
    set(checkoutDraftAtom, (draft) => ({ ...draft, ...patch }));
  },
);

export const resetCheckoutDraftAtom = atom(null, (_get, set) => {
  set(checkoutDraftAtom, emptyCheckoutDraft);
});

常见失败是把checkoutErrorsAtom的结果再保存到另一个 atom。草稿变了,错误快照却没变。给 Claude Code 的约束应写清:“能从当前 atom 推导的值不要保存。”

异步 atom 与服务器状态边界

async atom 很方便,但不是所有请求的归宿。Jotai async read atom 可以返回 Promise,并配合 Suspense 显示 fallback。它适合一个 UI 区域里较小的读取。

import { Suspense } from "react";
import { atom, useAtomValue, useSetAtom } from "jotai";

type Profile = {
  id: string;
  name: string;
  plan: "free" | "pro";
};

export const profileIdAtom = atom("masa");

export const profileAtom = atom(async (get, { signal }) => {
  const id = get(profileIdAtom);
  const response = await fetch(`/api/profiles/${id}`, { signal });

  if (!response.ok) {
    throw new Error("Failed to load profile");
  }

  return (await response.json()) as Profile;
});

function ProfileCard() {
  const profile = useAtomValue(profileAtom);
  return <p>{profile.name} is on the {profile.plan} plan.</p>;
}

function ProfileSwitcher() {
  const setProfileId = useSetAtom(profileIdAtom);
  return (
    <button type="button" onClick={() => setProfileId("demo")}>
      Load demo user
    </button>
  );
}

export function ProfilePanel() {
  return (
    <>
      <ProfileSwitcher />
      <Suspense fallback={<p>Loading profile...</p>}>
        <ProfileCard />
      </Suspense>
    </>
  );
}

如果需要重试、stale time、分页、乐观更新、写入后的 invalidate,应使用服务器状态库。Jotai 可以保存请求参数和 UI 状态,但响应缓存不应放进这些 atom。

atom family、SSR 与 Provider 陷阱

当每一行或每一个 tab 都需要独立 UI 状态时,atom family 很有用。风险是缓存会持续增长。官方文档说明 family 内部类似 Map,未移除的无限参数会造成内存泄漏。新代码使用jotai-family,旧代码要留下迁移任务。

import { atom } from "jotai";
import { atomFamily } from "jotai-family";

type RowUi = {
  expanded: boolean;
  selected: boolean;
};

export const rowUiFamily = atomFamily((id: string) =>
  atom<RowUi>({ expanded: false, selected: false }),
);

rowUiFamily.setShouldRemove((createdAt) => {
  return Date.now() - createdAt > 10 * 60_000;
});

export const removeRowUiAtom = atom(null, (_get, _set, id: string) => {
  rowUiFamily.remove(id);
});

SSR 的陷阱是 Provider 边界。Jotai 没有 Provider 也能运行,但按请求注入初始值、隔离子树、测试注入状态时,显式 Provider 更清楚。Next.js App Router 中,useHydrateAtoms应放在 client component 中。

"use client";

import { type PropsWithChildren } from "react";
import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { tasksAtom, type Task } from "./TaskBoard";

type Props = PropsWithChildren<{
  initialTasks: Task[];
}>;

function HydrateAtoms({ initialTasks, children }: Props) {
  useHydrateAtoms(new Map([[tasksAtom, initialTasks]]));
  return children;
}

export function JotaiRequestProvider(props: Props) {
  return (
    <Provider>
      <HydrateAtoms initialTasks={props.initialTasks}>
        {props.children}
      </HydrateAtoms>
    </Provider>
  );
}

失败模式是用户或租户切换时,试图在同一个 store 上反复 hydrate。Jotai SSR 工具默认更适合一次性注入。真实切换时,给 Provider 换 key 重新挂载,或设计显式 reset action。

测试与安全提示词

Jotai Testing guide 建议按用户交互测试组件,把 Jotai 当成实现细节。对 Claude Code 输出也应这样验收:输入、点击、结果显示、reset 行为都要覆盖。

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider } from "jotai";
import { describe, expect, it } from "vitest";
import { TaskBoard } from "./TaskBoard";

describe("TaskBoard", () => {
  it("adds and completes a task", async () => {
    const user = userEvent.setup();

    render(
      <Provider>
        <TaskBoard />
      </Provider>,
    );

    await user.type(screen.getByLabelText("New task"), "Review atoms");
    await user.click(screen.getByRole("button", { name: "Add" }));

    expect(screen.getByText("Review atoms").textContent).toBe("Review atoms");

    await user.click(
      screen.getByRole("button", { name: "Mark Review atoms done" }),
    );

    expect(screen.getByText(/Done: 1/).textContent).toContain("Done: 1");
  });
});

把提示词写成可审查的任务:

读取现有 React + TypeScript 画面,用 Jotai v2 整理状态。
只允许修改 src/features/tasks 下的文件。
不要把 API 响应保存到 atoms。
atoms 只用于 UI 状态和表单草稿。
请包含 derived atoms、write-only atoms、Provider 边界和 Vitest 测试。
如果必须使用 atom family,请用 jotai-family 并加入清理逻辑。
最后列出失败模式、再渲染风险和 SSR 风险。

再用设置文件排除敏感信息:

{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./secrets/**)",
      "Read(./build)"
    ]
  }
}

变现 CTA 与实际验证

Jotai 文章不应只停在代码片段。读者真正需要的是判断规则:哪些状态进 atoms,哪些交给 TanStack Query,哪些必须留在服务端。如果你要把 Claude Code 提示词、CLAUDE.md、review 规则和测试证据做成团队流程,可以从免费 Claude Code 清单开始,再对照Zustand 状态管理TanStack Query测试策略

Masa 在一个小 React 画面上试验后发现,最有效的步骤发生在写 atom 之前。第一次提示没有边界,Claude Code 把 API 响应、表单草稿和 toast 混在一个 atom 里。第二次明确写出“服务器数据不进 atom”“派生值不保存”“必须有 Provider 和测试”后,diff 明显变小,Vitest 覆盖了新增、完成和 reset。最容易漏掉的仍然是 atom family cleanup 和 SSR hydrate 边界。

#Claude Code #Jotai #React #状态管理 #atoms
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。