Jotaiのatomで状態管理を組む:派生・非同期・Zustandとの使い分け
Jotaiのatomの基本から派生atom・非同期atom・Provider不要のしくみ、ZustandやReduxとの違いと選びどころまで。コピペで動くタスクボード付き。
Reduxを入れたら、actions と reducers と selectors のフォルダが増えて、たった1個のモーダルを開閉するために3ファイルいじるハメになった。
僕が初めてReactの状態管理で「これ、やりすぎでは?」と思った瞬間です。トグル1つに大げさすぎる。かといって useState を親までバケツリレーするのも、子から孫へ props を引き回すのがしんどい。
そこで触ってみたのが Jotai でした。atom という小さな箱を1個作るだけ。Providerで囲む手間もない。最初の30分で「あ、これ僕が欲しかったやつだ」と腑に落ちました。
ただし、軽いがゆえに油断すると散らかります。atomをグローバル変数のノリで増やすと、画面をまたいだ入力・APIレスポンス・選択中のタブが同じ箱に混ざって、後から「なんでこの画面だけ再描画が多いの?」を延々追うことになる。実際、僕はそれで一度やらかしました。
この記事では、Jotaiのatomを「値そのもの」ではなく**「値を読むための設計図」**として扱う考え方を中心に、基本・派生・非同期、そしてZustandやReduxとの違いまで一気に整理します。
この記事の要点
- atomは値を持たない。 値はstoreにあり、atomは「どこの値をどう読むか」の設計図。公式も「The atom config object doesn’t hold a value」と明言しています。
- Provider不要で始められる。 Providerなしの「provider-less mode」が標準。囲むのはサブツリーごとに別の値を持ちたいときだけ。
- 派生atomで計算結果を保存しない。 合計・絞り込み・バリデーションは元データから毎回計算する。保存するとズレる。
- 非同期atomはUI内の小さな読み取り向き。 一覧のキャッシュ・再取得・stale判定が要るならTanStack Queryへ逃がす。
- 使い分けの軸はシンプル。 UI状態=Jotai/Zustand、サーバー状態=TanStack Query。ボイラープレートが嫌ならReduxよりJotai。
atomは「値の箱」じゃなくて「読み方の設計図」
ここを外すと全部つまずくので、最初に固定します。
Jotaiの atom() は、状態の最小単位を作る関数です。ただしatom自身は値を持ちません。公式ドキュメントもはっきりこう書いています——「The atom config object doesn’t hold a value. The atom value exists in a store(atom設定オブジェクトは値を持たない。値はstoreに存在する)」。
たとえるなら、atomは「冷蔵庫のどの棚に何を置くか」を書いたラベルです。中身(食材=値)は冷蔵庫(store)の中にある。同じラベルでも、別の冷蔵庫を用意すれば中身は別物になる。これが後で出てくるProviderの話につながります。
atom() が取れる形は4つだけ。これさえ押さえれば9割書けます。
| 書き方 | 種類 | 役割 |
|---|---|---|
atom(初期値) | プリミティブatom | ただの値の置き場 |
atom(get => ...) | 読み取り専用の派生atom | 他のatomから計算した値を読む |
atom(null, (get, set) => ...) | 書き込み専用atom | 操作(更新ロジック)を1か所にまとめる |
atom(read, write) | 読み書きatom | 上2つを合体させたもの |
読み取り関数(get => ...)は、get で参照した他のatomを自動で依存に登録します。依存先が変わると、この派生atomも自動で再計算される。書き込み関数((get, set) => ...)は、set で他のatomを更新するための入口です。ここの get は依存を追跡しない、という細かい違いもありますが、まずは「読み=計算、書き=操作」と覚えれば十分です。
Provider不要、がどれだけ楽か
ReduxやContextに慣れていると、「状態を使うなら、まずアプリをプロバイダで囲む」のが当たり前に感じます。Jotaiはそれが要りません。
公式いわく「If an atom is used in a tree without a Provider, it will use the default state. This is so-called provider-less mode(Providerなしのツリーでatomを使うと、デフォルトの状態を使う。これがprovider-less modeだ)」。つまり atom を作って useAtom するだけで、もう動きます。
import { atom, useAtom } from "jotai";
// これだけ。Providerで囲む必要なし
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<button type="button" onClick={() => setCount((c) => c + 1)}>
count is {count}
</button>
);
}
じゃあProviderは何のためにあるのか。公式が挙げる理由は3つだけです。
- サブツリーごとに別の状態を持ちたいとき(同じatomを別々の値で使い回す)
- atomの初期値を外から渡したいとき(SSRでサーバーから初期値を流す等)
- 再マウントで全atomをまとめてクリアしたいとき
小さなアプリや、状態がアプリ全体で1つでいい場面なら、Providerは省いてOK。「囲まないと動かない」という思い込みを捨てるだけで、初手のハードルがぐっと下がります。
まず動く:コピペできるタスクボード
説明より手を動かしたほうが早いです。プリミティブatom・派生atom・書き込み専用atomを1つの流れに入れた、最小のタスクボードを置きます。ViteやNext.jsの既存Reactアプリにそのまま貼れます。
まずインストール。
npm i jotai
npm i -D vitest @testing-library/react @testing-library/user-event
本体です。日本語コメントで「なぜそう書くか」を添えています。
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());
// プリミティブatom:これが「正」となる元データ
export const tasksAtom = atom<Task[]>([
{ id: "task-1", title: "リリースノートを書く", status: "todo" },
]);
// UIだけの状態。元データとは分けて持つ
export const filterAtom = atom<TaskStatus | "all">("all");
export const draftTitleAtom = atom("");
// 派生atom:絞り込み結果は「保存しない」。読むたびに計算する
export const visibleTasksAtom = atom((get) => {
const filter = get(filterAtom);
const tasks = get(tasksAtom);
return filter === "all"
? tasks
: tasks.filter((task) => task.status === filter);
});
// 派生atom:件数も計算で出す。元データが変われば自動で追従する
export const taskStatsAtom = atom((get) => {
const tasks = get(tasksAtom);
return {
total: tasks.length,
done: tasks.filter((task) => task.status === "done").length,
};
});
// 書き込み専用atom:追加の操作を1か所にまとめる
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);
// useAtomValue=読むだけ。必要なatomだけを購読して再描画を絞る
const tasks = useAtomValue(visibleTasksAtom);
const stats = useAtomValue(taskStatsAtom);
// useSetAtom=書くだけ。値を読まないので余計な再描画が起きない
const addTask = useSetAtom(addTaskAtom);
const toggleTask = useSetAtom(toggleTaskAtom);
return (
<section>
<p>
合計: {stats.total} / 完了: {stats.done}
</p>
<label>
新しいタスク
<input
value={draft}
onChange={(event) => setDraft(event.currentTarget.value)}
/>
</label>
<button type="button" onClick={addTask}>
追加
</button>
<select
value={filter}
onChange={(event) =>
setFilter(event.currentTarget.value as TaskStatus | "all")
}
>
<option value="all">すべて</option>
<option value="todo">未着手</option>
<option value="doing">作業中</option>
<option value="done">完了</option>
</select>
<ul>
{tasks.map((task) => (
<li key={task.id}>
<span>{task.title}</span>
<button
type="button"
aria-label={`${task.title} を完了にする`}
onClick={() => toggleTask(task.id)}
>
{task.status === "done" ? "戻す" : "完了"}
</button>
</li>
))}
</ul>
</section>
);
}
ここで効いている設計のポイントは3つです。visibleTasksAtom は絞り込み結果を保存していない(毎回計算)。addTaskAtom は入力の trim と入力欄のリセットを同じ操作にまとめている。そして各コンポーネントは useAtomValue と useSetAtom で必要なatomだけを購読しているので、関係ない更新で再描画されません。
useAtom / useAtomValue / useSetAtom の使い分けは地味ですが大事です。読むだけなら useAtomValue、書くだけなら useSetAtom。両方要るときだけ useAtom。「書くだけのボタン」に useAtom を使うと、値を購読してしまって無駄に再描画されます。
派生atomと書き込み専用atomを使い分ける
Jotaiが気持ちいいのは、この2つを覚えてからです。
派生atomは、既存のatomから値を計算するatom。合計数・絞り込み結果・バリデーション結果のように、元データから常に導ける値は保存しません。元が変われば自動で追従するからです。
書き込み専用atomは、画面から呼ぶ操作を1か所にまとめるためのもの。フォームの patch、reset、submit準備のような「手続き」を閉じ込めるのに向きます。下の例は、入力フォームのバリデーションを派生atomで、更新とリセットを書き込み専用atomでやっています。
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);
// バリデーション結果は派生atom。下書きが変われば毎回計算し直される
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;
});
// 部分更新を1か所に。画面からは patch を呼ぶだけ
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に保存してしまうこと。下書きが変わったのにエラー表示だけ古い、というズレが必ず起きます。原則は1つ——「元データから導ける値は保存しない」。これを守るだけで、状態のバグは体感で半分になります。
非同期atomはどこまで使う?
Jotaiの async atom は便利ですが、すべてのAPI取得を置く場所ではありません。ここを勘違いすると後で苦しみます。
async read atom は、読まれたときに Promise を返し、Reactの <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");
// 非同期の派生atom。signal で中断にも対応
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("プロフィールの取得に失敗しました");
}
return (await response.json()) as Profile;
});
function ProfileCard() {
// Suspense と組み合わせると、解決済みの値だけが渡る
const profile = useAtomValue(profileAtom);
return <p>{profile.name} は {profile.plan} プランです。</p>;
}
function ProfileSwitcher() {
const setProfileId = useSetAtom(profileIdAtom);
return (
<button type="button" onClick={() => setProfileId("demo")}>
デモユーザーを読み込む
</button>
);
}
export function ProfilePanel() {
return (
<>
<ProfileSwitcher />
<Suspense fallback={<p>読み込み中...</p>}>
<ProfileCard />
</Suspense>
</>
);
}
profileIdAtom を変えると profileAtom が再取得される、という連鎖が自然に書けるのがJotaiらしいところです。
ただし、一覧のキャッシュ、リトライ、stale判定、更新後の再取得(invalidate)が要るなら、そこは TanStack Query の領分です。線引きはシンプルで、Jotaiは「その画面のUI状態」と「少量の派生・非同期」に強く、サーバー状態ライブラリは「サーバーが正とするデータ」に強い。両者は競合じゃなくて分担です。サーバー状態側の設計はClaude CodeでTanStack Queryを実装するガイドで詳しく書いています。
Jotai / Zustand / Redux / Recoil の違いと選びどころ
「結局どれ使えばいいの?」に答えます。僕の実感ベースの比較です。
| ライブラリ | モデル | Provider | 向いている場面 |
|---|---|---|---|
| Jotai | atom(ボトムアップ) | 基本不要 | 細かいUI状態、派生値、コンポーネント密着 |
| Recoil | atom/selector | 必要(RecoilRoot) | 思想はJotaiに近い。ただし開発停滞気味 |
| Zustand | 単一store(トップダウン) | 不要 | アプリ全体で1つの大きな状態、store中心 |
| Redux Toolkit | 単一store + reducer | 必要 | 大規模・厳格な規約・履歴やdevtools重視 |
ざっくりした選び方はこうです。
- コンポーネントに近い細かい状態を、増えても散らからないように持ちたい → Jotai。atomを足すだけで増やせる。
- アプリ全体で共有する1つの大きな状態を、1ファイルにまとめたい → Zustand。storeをトップダウンで設計する発想。
- チームが大きく、厳格な規約・action履歴・タイムトラベルが欲しい → Redux Toolkit。冒頭の僕みたいに「トグル1つで3ファイル」が許容できる規模なら正解。
- Recoilを新規採用 → 今は様子見が無難。思想はJotaiとよく似ていて、Jotaiのほうがメンテが活発です。
JotaiとZustandは特に迷いどころですが、軸は「ボトムアップか、トップダウンか」。小さなatomを積み上げる感覚ならJotai、storeを1つ設計して切り出す感覚ならZustand。グローバルstore寄りの設計はClaude CodeでZustandの状態管理を設計するガイドにまとめてあるので、迷ったら読み比べてください。
僕がJotaiでやらかした失敗3つ
正直に書きます。最初のJotaiは散らかっていました。
1つ目は、1つのatomに詰め込みすぎたこと。 管理画面で、フィルター・取得済み行・選択中の行・保存中フラグ・toastを全部1個のatomに入れました。最初は「1か所で見えて楽」に思えた。でも編集後に一覧へ戻ると古い選択行だけが残り、別の一括操作に巻き込まれた。API結果はサーバー状態、選択行は画面状態、toastは短命なUI状態、と箱を分けた瞬間にバグが消えました。
2つ目は、派生値を保存したこと。 絞り込み結果を「速くなる気がして」別atomにキャッシュしたら、元データが変わったのに表示だけ古いまま。派生は計算で出す、が鉄則です。Jotaiは依存追跡が賢いので、素直に派生atomにしたほうが速くて安全でした。
3つ目は、atomWithStorage に何でも入れたこと。 テーマや表示密度をlocalStorageに永続化するのは便利です。が、調子に乗ってトークンやメールアドレスまで入れかけました。個人情報・token・住所・決済関連はlocalStorageに置かない。ここはサーバー側に寄せる、と線を引いてから安心して使えるようになりました。
よくある質問
Q. JotaiにProviderは本当に要らないの? A. 小さなアプリや、状態がアプリ全体で1つでいいなら要りません(provider-less mode)。サブツリーごとに別の値を持つ、SSRで初期値を渡す、再マウントで全クリアする——この3つのどれかが必要になったらProviderを置きます。
Q. JotaiとZustand、どっちを選べばいい? A. 細かいUI状態をボトムアップに積み上げるならJotai、アプリ全体の大きな状態を1つのstoreにまとめるならZustand。どちらもサーバー状態には不向きなので、API取得はTanStack Queryに分けるのが安全です。
Q. APIから取ったデータはatomに入れていい?
A. 一覧のキャッシュ・再取得・stale判定が要るデータは入れないほうが無難です。それはTanStack Queryの役目。画面内で完結する小さな読み取りなら非同期atom + <Suspense> で十分です。
Q. 派生atomと普通のatom、どう見分ける? A. 「他の値から計算で出せるか」で判断します。合計・絞り込み・バリデーションは計算で出せるので派生atom。ユーザーが直接入力する値や、サーバーから来た元データはプリミティブatomです。
Q. RecoilからJotaiに移れる? A. 思想(atom/selector)が近いので発想は移しやすいです。Recoilのselectorに当たるのがJotaiの派生atom。RecoilRootが必須だったのに対し、JotaiはProvider省略可なぶん導入は軽くなります。
まとめ:atomは「設計図」、保存より計算
Jotaiは、atom という小さな箱を積み上げる状態管理です。鍵になる考え方は最初に書いた通り——atomは値を持たず、値はstoreにある。atomは「読み方の設計図」。これさえ腹落ちすれば、Provider不要のシンプルさも、派生atomで計算結果を保存しない理由も、全部つながります。
迷ったら原則は2つだけ。「元データから導ける値は保存しない」「サーバー状態はTanStack Queryに逃がす」。この線引きを守るだけで、Jotaiの軽さは散らからずに効きます。冒頭の僕みたいに「トグル1つで3ファイル」に疲れているなら、まずは上のタスクボードを貼って、atomを1個足すところから始めてみてください。
非同期の前提となる<Suspense>の公式リファレンスと、atomの正確な挙動はJotai公式のatomドキュメントが一次情報です。チームでReactの状態管理ルールを揃えたいなら研修・導入相談も覗いてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。