Jotai Atoms With Claude Code: Practical React State Guide
Design Jotai atoms with Claude Code: derived, async, SSR, Provider boundaries, tests, and safe prompts.
Asking Claude Code to “add Jotai state management” is too vague. You may get working code, but the atom size, server-state boundary, Provider placement, and tests will be accidental. The result is usually a screen that works in the happy path and becomes hard to reason about when filters, forms, async reads, and SSR meet.
This guide treats Jotai atoms as a design tool for small React state, not as a generic global store. The official Jotai atom documentation explains that an atom config is a definition and does not hold a value; atom values live in a store. Async examples are based on the Jotai async guide and React’s official <Suspense> reference. Provider and SSR notes come from the Jotai Provider docs and SSR utilities.
On the Claude Code side, the official Claude Code overview describes an agentic coding tool that reads a codebase, edits files, and runs commands. That power needs scope. The settings documentation documents permissions.deny, CLAUDE.md, and settings precedence, so this article includes safe prompt patterns and a minimal permissions example. For wider React context, read the Claude Code React guide. For server data, compare the TanStack Query guide.
Start With the Atom Mental Model
The first rule is simple: an atom is not the value itself. It is a stable definition that tells Jotai how to read or write a value in a store. If Claude Code misses this distinction, it tends to create one large pageStateAtom with search text, API responses, selected rows, toast messages, and form drafts mixed together.
Before writing code, decide three boundaries. Is the value UI state or server truth? Is it shared by distant components or local to one component? Can it be derived from another atom instead of stored? Search text, filters, active tabs, short toast state, and multi-step form drafts are good Jotai candidates. Product lists, user tables, authentication tokens, payment sessions, and inventory truth usually belong in a server-state tool or on the server.
| State | Good Jotai fit | Avoid |
|---|---|---|
| Form draft | Shared across wizard steps | Persisting a whole saved order |
| Dashboard filters | Drives table, count, and URL sync | Storing full API responses |
| Modal and toast | Opened from distant buttons | Keeping long audit logs |
| User preference | Theme or density | Tokens, email, address, payment data |
Masa’s practical failure on an admin page was using “add Zustand or Jotai” as the goal instead of creating a state inventory. The first version stored filters, fetched rows, selected row IDs, saving state, and toast messages together. After navigating back, stale selection affected a new bulk action. Splitting server data, selection state, and short-lived UI state made the Claude Code diff smaller and the review clearer.
Install and Build a Minimal Slice
For a Vite or Next.js React app, install Jotai first. If you need atom families in new work, prefer jotai-family: the current Jotai docs mark the old jotai/utils atomFamily path as deprecated for Jotai v3.
npm i jotai jotai-family
npm i -D vitest @testing-library/react @testing-library/user-event
The following task board is intentionally small but complete. It includes primitive atoms, derived atoms, and write-only atoms in one copyable React slice.
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>
);
}
Ask Claude Code to review three things after it generates a slice like this: derived atoms do not store duplicate values, write-only atoms keep update rules in one place, and components subscribe only to the atoms they render.
Use Cases That Set the Right Granularity
The first concrete use case is an admin filter bar. Put search, status, page, and maybe sort in atoms when they are used by a table, a count chip, and URL sync. Do not put the fetched table response in the same atom. Tell Claude Code: “URL-visible conditions and UI-only conditions must be separate; API responses must stay in the server-state layer.”
The second use case is a multi-step checkout or onboarding form. Draft fields, current step, and validation state fit well in Jotai. Submitted orders, payment sessions, and inventory checks do not. A write-only resetDraftAtom after success is safer than resetting fields from several components.
The third use case is detail-page UI state. Expanded rows, selected tab, selected ID, and a short toast queue can be split into small atoms so only the affected component re-renders. A single detailPageAtom is easier to generate but harder to review.
The fourth use case is preferences. Theme, density, and dismissed hints can use storage helpers, but private user data should not be persisted in the browser. This matters on monetized pages too: CTA open state is low risk, but buyer emails and coupon history belong on the server.
Derived and Write-Only Atoms
Derived atoms compute values from other atoms. Counts, filtered lists, and validation results should usually be derived instead of stored. Write-only atoms centralize actions such as patching a form, resetting a draft, or normalizing input before saving.
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);
});
A common failure mode is storing checkoutErrorsAtom in another atom. The draft changes, but the error snapshot stays old. The prompt should say: “Do not store values that can be derived from current atoms.”
Async Atoms and Server-State Boundaries
Async atoms are useful, but they are not a replacement for every data-fetching concern. Jotai async read atoms can return a Promise, and React Suspense can show a fallback while the child tree is loading. This is a good fit for small reads inside one UI area.
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>
</>
);
}
If you need retry rules, stale time, optimistic mutations, pagination, or invalidation after writes, use a server-state library and keep Jotai for the UI state around it. A useful phrase for Claude Code is: “Jotai may hold request parameters and local UI state; the server response cache must stay outside these atoms.”
Atom Families, SSR, and Provider Pitfalls
Atom families are useful when each row or tab needs its own local UI state. The official docs also warn that a family keeps atoms in a cache, so unbounded params can leak memory unless unused keys are removed. New work should use jotai-family; old code using jotai/utils needs a migration note.
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 has a different trap. Jotai can run without a Provider, but explicit Providers are better when you need per-request initial values, subtree isolation, or clean tests. In Next.js App Router, useHydrateAtoms belongs in a 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>
);
}
The failure mode is trying to rehydrate the same atom over and over for a changed user or tenant. The SSR utility hydrates a given store once by default. For real user switching, remount the Provider with a new key or add a deliberate reset action.
Tests and Safe Claude Code Prompts
The Jotai testing guide recommends testing the way users interact with components and treating Jotai as an implementation detail. That is the right default for Claude Code output: verify input, click, visible result, and reset behavior.
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");
});
});
Use a scoped prompt instead of a broad generation request:
Read the existing React + TypeScript screen and reorganize state with Jotai v2.
Only edit files under src/features/tasks.
Do not store API responses in atoms.
Use atoms only for UI state and form drafts.
Include derived atoms, write-only atoms, a Provider boundary, and Vitest tests.
If an atom family is necessary, use jotai-family and add cleanup.
End with a critical review of failure modes, render risk, and SSR risk.
Also configure Claude Code so secrets are outside the tool’s view:
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Read(./build)"
]
}
}
Monetization CTA and Next Steps
Jotai content should not stop at isolated snippets. Readers need a decision rule: which state belongs in atoms, which state belongs in TanStack Query, and which state belongs on the server. If you are building a team workflow around Claude Code prompts, CLAUDE.md, review rules, and test receipts, start with the free Claude Code checklist and then compare Zustand state management, TanStack Query, and Claude Code testing strategies.
Hands-On Verification Note
When Masa tested this pattern on a small React screen, the biggest improvement came before any atom was written. The first Claude Code prompt mixed API responses, form draft state, and toast state into one atom. The second prompt explicitly said “server data is out of scope,” “derived values must not be stored,” and “Provider plus tests are required.” The resulting diff was smaller, the Vitest flow covered add, complete, and reset behavior, and the review caught two easy-to-miss issues: atom family cleanup and SSR hydration boundaries.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.