Claude CodeでJotai atomsを設計する実践ガイド
Jotai atomsをClaude Codeで安全に設計する手順。派生、非同期、SSR、テストまで実例で解説。
Claude Codeに「Jotaiで状態管理を作って」とだけ頼むと、動くコードは出ます。しかし、atomsの粒度、非同期データの置き場、SSR時のProvider境界、テスト方法を先に決めないと、あとで「なぜこの画面だけ再描画が多いのか」「なぜ別ユーザーの初期値が混ざるのか」を追うことになります。
この記事では、React初心者でも追えるように、Jotaiのatomを「値そのもの」ではなく「値を読むための設計図」として扱います。公式のJotai atomドキュメントでは、atom configは値を保持せず、値はstoreに存在すると説明されています。非同期はJotai async guideとReactの<Suspense>公式リファレンス、Provider境界はJotai ProviderとSSR utilitiesを基準にします。
Claude Code側は、Claude Code overviewが説明する通り、コードベースを読み、編集し、コマンドを実行できるエージェント型ツールです。だからこそ、.envや秘密情報を読ませない設定はClaude Code settingsのpermissions.denyに寄せ、依頼文では「変更範囲」「テスト」「触ってよいファイル」を明記します。React全体の前提はClaude Code React開発ガイド、サーバー状態の比較はClaude CodeでTanStack Queryを活用するガイドも合わせて確認してください。
atomsの考え方を先に固定する
Jotaiのatomは、小さな状態の単位です。ただし、atomを「グローバル変数」と考えると失敗します。atom configは定義であり、実際の値はProvider配下のstoreに入ります。この違いをClaude Codeに伝えないまま実装させると、画面をまたいだ一時入力、APIレスポンス、選択中のタブ、フォーム下書きが同じ場所に混ざります。
最初に決めることは3つです。1つ目は、UIだけの状態か、サーバーが正とする状態か。2つ目は、1画面だけで完結するか、離れたコンポーネントで共有するか。3つ目は、派生値を保存するのか、読むたびに計算するのかです。たとえば「検索語」「フィルター」「モーダルの開閉」はJotaiと相性がよいです。一方で「商品一覧のAPIレスポンス」「認証トークン」「決済結果」は、キャッシュ、再取得、期限、セキュリティの問題があるため、TanStack Queryやサーバー側セッションに寄せる方が安全です。
| 状態 | Jotaiに置く判断 | 避ける判断 |
|---|---|---|
| フォーム下書き | 複数ステップで共有する | サーバー保存済みデータを丸ごと保持する |
| ダッシュボードフィルター | 表、件数、URL同期で使う | API結果まで同じatomに入れる |
| モーダルやtoast | 離れたボタンから開く | 長いエラーログを保持する |
| ユーザー設定 | テーマ、表示密度など低リスク | token、メール、住所を永続化する |
Masaが管理画面で失敗したときは、フィルター、取得済み行、選択行、保存中フラグ、toastを1つのatomに入れていました。レビューは楽に見えましたが、編集後に一覧へ戻ると古い選択行だけが残り、別の一括操作に混ざりました。境界を分けて、API結果はサーバー状態、選択行は画面状態、toastは短命UI状態にしただけで、Claude Codeの修正差分も小さくなりました。
まず動く最小構成
ViteやNext.jsの既存Reactアプリなら、Jotai本体と、必要に応じてatom family用のパッケージを入れます。Jotai公式ドキュメントではjotai/utilsのatomFamilyがJotai v3で非推奨予定と案内されているため、新規プロジェクトではjotai-familyを選ぶ前提でClaude Codeに依頼するのが無難です。
npm i jotai jotai-family
npm i -D vitest @testing-library/react @testing-library/user-event
以下は、コピペして動かしやすいタスクボードの例です。primitive atom、derived atom、write-only atomを1つの流れに入れています。
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にレビューさせる観点は、visibleTasksAtomが派生値を保存していないこと、addTaskAtomが入力のtrimとresetを同じactionにまとめていること、UIコンポーネントが必要なatomだけを購読していることです。
3つ以上のユースケースで粒度を決める
1つ目のユースケースは、管理画面のフィルターです。検索語、ステータス、ページ番号はJotaiに置けますが、取得済み一覧はAPIキャッシュに置きます。Claude Codeには「URLに出す条件とUIだけの条件を分ける」「一覧レスポンスをatomに保存しない」と明記します。
2つ目は、複数ステップのフォームです。配送先、請求先、同意チェックのような下書きはJotaiで扱いやすいですが、送信済み注文や決済セッションはサーバー状態です。フォーム下書き用のatomは、送信成功時にresetするwrite-only atomを用意すると、戻るボタンや再送信時の事故を減らせます。
3つ目は、詳細画面のUI状態です。行の展開、タブ、選択中ID、toastは小さなatomに分けると、関連する部品だけが再描画されます。大きなpageStateAtomに全部入れるより、selectedTaskIdAtom、activeTabAtom、toastAtomに分ける方がClaude Codeの差分レビューもしやすいです。
4つ目は、ユーザー設定です。テーマや表示密度はatomWithStorageで永続化できます。ただし個人情報、token、住所、決済関連はlocalStorageへ入れないでください。収益導線を持つ記事サイトでも、CTAの開閉状態や表示回数は低リスクですが、購入者メールやクーポンの利用履歴はサーバー側に置きます。
派生atomとwrite-only atomを使い分ける
派生atomは、既存のatomから値を計算するatomです。合計数、絞り込み結果、バリデーション結果のように、元データから常に計算できる値は保存しません。write-only atomは、画面から呼ぶ操作を1箇所にまとめるために使います。フォームのpatch、reset、submit準備のような処理に向いています。
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 = "メールアドレスを確認してください";
}
if (!/^\d{3}-?\d{4}$/.test(draft.postalCode)) {
errors.postalCode = "郵便番号は7桁で入力してください";
}
if (!draft.agreed) {
errors.agreed = "利用規約への同意が必要です";
}
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には「元データから導ける値を保存しない」「write-only atomは副作用を小さくする」と依頼します。
非同期atomとサーバー状態の境界
Jotaiのasync atomは便利ですが、すべてのAPI取得を置く場所ではありません。Jotaiのasync read atomは、読まれた時にPromiseを返し、Suspenseと組み合わせて表示できます。ユーザープロフィールのように画面内で必要な小さな読み取りには使いやすいです。
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判定、mutation後のinvalidateが必要なら、TanStack Queryに寄せる方が保守しやすいです。Jotaiは「その画面のUI状態」と「少量の派生状態」に強く、サーバー状態ライブラリは「サーバーが正とするデータ」に強い、と分けて考えます。
atomFamily、SSR、Providerの落とし穴
atomFamilyは、IDごとにatomを作りたい時に便利です。ただし公式ドキュメントでは、内部的にMapでatom configを保持するため、無限に増えるIDを明示的に削除しないとメモリリークにつながると説明されています。新規ではjotai-familyを使い、既存のjotai/utils利用は移行計画を持たせます。
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境界も重要です。ProviderなしでもJotaiはprovider-less modeで動きますが、リクエストごとの初期値やテスト分離が必要な画面では明示的なProviderを置きます。Next.js App Routerでサーバーから初期値を渡す場合、useHydrateAtomsはクライアント側コンポーネントで使います。
"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>
);
}
失敗例は、サーバーで計算した初期値をあとから何度もhydrateしようとすることです。JotaiのSSR utilitiesは、同じstoreでは基本的に一度だけhydrateされる前提です。ユーザー切り替えやテナント切り替えでは、Providerのkeyを変えてstoreを作り直すか、明示的なreset actionを用意します。
テストとClaude Codeへの安全な依頼
Jotai公式のTesting guideは、atomを実装詳細として扱い、ユーザー操作に近いテストを書く方針を示しています。つまり、get(atom)だけを確認するより、画面に入力して、ボタンを押し、結果の表示を確認します。
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");
});
});
Claude Codeへの依頼は、次のように制約を先に書きます。
既存のReact + TypeScript画面を読み、Jotai v2前提で状態を整理してください。
触ってよいのはsrc/features/tasks配下だけです。
APIレスポンスはatomに保存せず、UI状態とフォーム下書きだけをatomsにしてください。
derived atom、write-only atom、Provider境界、Vitestテストを入れてください。
atomFamilyが必要ならjotai-familyを使い、removeまたはsetShouldRemoveを実装してください。
最後に失敗例、再描画リスク、SSRリスクを箇条書きでレビューしてください。
機密ファイルの除外は設定にも書きます。記事用の検証リポジトリでも、Claude Codeが.envやbuild成果物を読まないようにしておくと、スクリーンショットや記事本文に秘密情報が混ざる事故を減らせます。
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Read(./build)"
]
}
}
収益導線と運用CTA
Jotaiの記事は、単なるサンプル集で終わらせると収益につながりにくいです。読者が欲しいのは「自分のReact画面でどこにJotaiを入れ、どこからTanStack QueryやZustandに逃がすか」です。チームでClaude Codeのプロンプト、CLAUDE.md、レビュー観点、テスト証跡を揃えるならClaude Code研修・導入相談へ進んでください。個人でまず手元の依頼文を整えるなら無料チートシートが入口になります。
比較対象としては、グローバルstore寄りならClaude CodeでZustand状態管理を設計する実装ガイド、サーバー状態寄りならClaude CodeでTanStack Queryを安全に実装する実践ガイド、テスト全体はClaude Codeテスト戦略を読むと判断しやすくなります。
この記事で紹介した内容を実際に試した結果
Masaが検証用のReact画面でこの構成を試したところ、一番効いたのは「atomを書く前の状態棚卸し」でした。最初にClaude Codeへ境界を書かずに依頼した版では、APIレスポンス、フォーム下書き、toastが1つのatomにまとまりました。次に「サーバー状態は保存しない」「派生値は保存しない」「Provider境界とテストを必須」と書いたところ、差分が半分ほどになり、Vitestで追加、完了、resetまで確認できました。特にatomFamilyのcleanupとSSRのhydrate制約は、公式ドキュメントを参照させないと抜けやすい確認項目です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。