「Jotai 使い方を実務レベルで理解したい」「ReduxやZustandは触ったことがあるけど、Jotaiの原子的(atomic)な思想がまだ腑に落ちない」「atomFamilyやloadable、atomWithStorageといったユーティリティを、実際のプロジェクトでどう組み合わせれば良いのか分からない」――この記事はそんな現役ReactエンジニアのためのJotai完全実践ガイドです。Recoilの後継として2026年現在も着実にシェアを伸ばしているJotaiは、「Bottom-up型・原子的状態管理」という独自の思想を持ち、Zustandの単一ストア型ともReduxのFlux型とも異なるアプローチで状態を管理します。本記事では、atomの基本から、派生atom・書込み専用atom・atomFamily・atomWithStorage・atomWithReducer・atomWithReset・loadable・async atom・Suspense連携、さらにuseAtomValue/useSetAtomの分離・Provider Scope・jotai-tanstack-query連携・jotai-immer連携・selectAtomによるパフォーマンス最適化までを、30本超のTypeScriptコードブロック・表3つ・FAQ7問で徹底解説。読み終えるころには、あなたのプロジェクトでJotaiを採用するか・どのユーティリティを使うか・どう設計すれば再レンダリングを最小化できるかを、自分の言葉で説明できるようになっているはずです。
- Jotaiとは何か〜Bottom-up型原子的状態管理の思想
- インストールと最小構成
- 基本atom〜プリミティブatomの全パターン
- 派生atom(derived atom)〜計算済み状態を表現する
- 書込みatom(write-only / read-write atom)
- useAtomValueとuseSetAtomで再レンダリングを最小化
- atomFamily〜パラメータ付きatomの動的生成
- atomWithStorage〜永続化されたatom
- atomWithReducer・atomWithReset・atomWithDefault
- 非同期atom・Suspense・loadable
- useAtomCallbackとatomEffects
- Provider Scope〜複数ストアの分離
- パフォーマンス最適化〜selectAtomとsplitAtom
- jotai-tanstack-query連携〜サーバー状態をatom化
- jotai-immer〜複雑な更新をmutableに書く
- Devtools〜デバッグを快適にする
- TypeScript型注釈の実践パターン
- テスト戦略〜atomを単独でテストする
- 採用判断と他ライブラリとの比較
- よくある質問(FAQ)
- まとめ〜Jotaiは「Reactらしい状態管理」の現代解
Jotaiとは何か〜Bottom-up型原子的状態管理の思想
Jotai(状態を意味する日本語「状態(じょうたい)」が由来)は、Daishi Kato氏らが開発する、約3KBという軽量なReact向け状態管理ライブラリです。最大の特徴は「Bottom-up型・原子的(atomic)」な設計思想にあります。Reduxのような「Top-down型・単一ストア」とは正反対で、状態をatomと呼ばれる最小単位に分解し、必要なatomだけをコンポーネントが購読することで、再レンダリングを最小限に抑えるのがJotaiの核心です。
useStateの分散版としてのatom
Jotaiのatomは、ざっくり言えば「コンポーネント外に置けるuseState」です。useStateがコンポーネント内に閉じた状態を作るのに対し、atomはモジュールスコープに状態を定義し、複数のコンポーネントから参照・更新できます。React標準APIの拡張感覚で書けるため、Reduxのような大袈裟なボイラープレートが一切不要です。useState完全ガイドでuseStateの落とし穴を踏んだ経験がある人ほど、Jotaiの軽さに驚くはずです。
// 一番シンプルなJotai: countAtomを定義してuseAtomで使う
import { atom, useAtom } from "jotai";
// モジュールスコープでatomを定義(useStateと違いコンポーネント外でOK)
const countAtom = atom<number>(0);
export function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<button onClick={() => setCount((c) => c + 1)}>
count: {count}
</button>
);
}
ReduxやZustandとの設計思想の違い
同じ外部状態管理ライブラリでも、設計思想は大きく異なります。Reduxは単一ストアにすべての状態を集約する「Top-down」、Zustandは単一ストアだがフックベースで軽量、Jotaiはatomを起点に状態を分散させる「Bottom-up」です。クライアント側の包括的な比較はReact状態管理ライブラリ完全比較で扱っていますが、本記事はJotai実装に完全特化します。
Recoilの後継としての位置付け
Jotaiは設計者がRecoilの開発にも関わっていた経緯から、Recoilの後継として語られることが多いライブラリです。Recoilがメンテナンスモードに移行した2024年以降、移行先としてJotaiを選ぶプロジェクトが急増しました。Recoilで使われていたatom・selector・atomFamilyといった概念がほぼ踏襲されており、移行コストが低いのも採用される理由です。
インストールと最小構成
npm/pnpm/yarnでのインストール
Jotai本体のインストールは1コマンドで完了します。React 18以降・TypeScript 5系を前提に、次のいずれかを実行してください。
# npm
npm install jotai
# pnpm
pnpm add jotai
# yarn
yarn add jotai
# 関連ユーティリティをまとめて入れる場合
npm install jotai jotai-tanstack-query jotai-immer jotai-devtools
Providerは省略可能・必要なときだけ使う
Reduxと違い、JotaiはアプリのルートにProviderを置かなくても動きます(暗黙のデフォルトストアを使う)。これは小規模プロジェクトでの導入コストを大きく下げる設計上の工夫です。複数ストアを切り替えたい場合・テスト時に隔離したい場合のみ、明示的にProviderを使います。
// Providerなしの最小構成(暗黙のデフォルトストアが使われる)
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(<App />);
// 明示的にProviderを使う場合(複数ストアの隔離やテストで便利)
import { Provider } from "jotai";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<Provider>
<App />
</Provider>
);
基本atom〜プリミティブatomの全パターン
プリミティブ値を扱う基本atom
もっとも基本的な使い方は、初期値を渡してatomを生成し、useAtomでReact stateのように扱う形です。プリミティブatomは読み・書きの両方が可能で、TypeScriptではatom<T>(initial)で型を明示できます。
// プリミティブatomの定義例
import { atom } from "jotai";
export const countAtom = atom<number>(0);
export const userNameAtom = atom<string>("");
export const isOpenAtom = atom<boolean>(false);
export const todosAtom = atom<Todo[]>([]);
export type Todo = {
id: string;
title: string;
done: boolean;
};
オブジェクト・配列atomの注意点
オブジェクトや配列をatomに入れる場合、不変性(immutability)を厳守してください。useStateと同じく、state.push()のような破壊的更新ではReactが変化を検知できません。Jotaiは内部でObject.isで前後比較するため、必ず新しい参照を返すように書きます。
// NG: 破壊的更新(再レンダリングが走らない)
setTodos((prev) => {
prev.push({ id: "1", title: "牛乳を買う", done: false }); // ❌
return prev;
});
// OK: 不変な更新(新しい配列を返す)
setTodos((prev) => [
...prev,
{ id: "1", title: "牛乳を買う", done: false },
]); // ✅
派生atom(derived atom)〜計算済み状態を表現する
read関数だけのread-only atom
Jotai最強の機能の1つが、派生atom(derived atom)です。atom((get) => ...)と書くと、他のatomをgetで参照しながら、その値から計算された新しい値を返すatomを定義できます。これはRedux ToolkitのcreateSelectorに相当しますが、memoizeを自分で書く必要がなく、依存しているatomが変化したときだけ再計算されます。
// 派生atom: todosから未完了タスク数を計算
import { atom } from "jotai";
export const todosAtom = atom<Todo[]>([
{ id: "1", title: "牛乳を買う", done: false },
{ id: "2", title: "PR をレビューする", done: true },
]);
// read-only な派生atom(他のatomから値を計算)
export const remainingCountAtom = atom((get) => {
const todos = get(todosAtom);
return todos.filter((t) => !t.done).length;
});
// 使う側はuseAtomValueで読み取り専用にする
import { useAtomValue } from "jotai";
function RemainingBadge() {
const remaining = useAtomValue(remainingCountAtom);
return <span>未完了: {remaining}件</span>;
}
派生atomの連鎖(atomがatomを参照する)
派生atomは他の派生atomをgetすることもでき、依存グラフが自動的に構築されます。これにより、状態の正規化と派生値の表現がとてもクリーンになります。
// 派生atomを更に派生させる例
import { atom } from "jotai";
const priceAtom = atom<number>(1000);
const quantityAtom = atom<number>(2);
const taxRateAtom = atom<number>(0.1);
// 派生1: 小計
const subtotalAtom = atom((get) => get(priceAtom) * get(quantityAtom));
// 派生2: 税額(派生atomを更に派生)
const taxAtom = atom((get) => get(subtotalAtom) * get(taxRateAtom));
// 派生3: 合計
const totalAtom = atom((get) => get(subtotalAtom) + get(taxAtom));
書込みatom(write-only / read-write atom)
read-write atomで派生atomに書き込みロジックを持たせる
派生atomは「読み取り専用」が基本ですが、第2引数にwrite関数を渡すとread-write atomになります。これにより、「値を読むときの計算ロジック」と「値を書くときのロジック」を1つのatomにまとめることができ、actionとselectorを分けて書くReduxに比べて見通しが良くなります。
// read-write atom: 読みは派生・書きは別のatomを更新
import { atom } from "jotai";
const firstNameAtom = atom("太郎");
const lastNameAtom = atom("山田");
// 読み = 結合した文字列、書き = スペース区切りで分解して両方に書く
const fullNameAtom = atom(
(get) => `${get(lastNameAtom)} ${get(firstNameAtom)}`,
(_get, set, newFullName: string) => {
const [last, first] = newFullName.split(" ");
set(lastNameAtom, last ?? "");
set(firstNameAtom, first ?? "");
}
);
write-only atom(actionの代わりに使う)
read関数にnullを渡すことで、write専用のatomを作ることもできます。これはReduxのdispatch(action)に近いもので、「副作用を起こすためのトリガー」として活用されます。
// write-only atom: action代わりに使う
import { atom } from "jotai";
const counterAtom = atom(0);
// 読み取り不能・書き込み専用のatom(actionの代用)
const incrementAtom = atom(null, (get, set, by: number = 1) => {
set(counterAtom, get(counterAtom) + by);
});
// 利用側
import { useSetAtom } from "jotai";
function IncrementButton() {
const increment = useSetAtom(incrementAtom);
return <button onClick={() => increment(5)}>+5</button>;
}
useAtomValueとuseSetAtomで再レンダリングを最小化
useAtom・useAtomValue・useSetAtomの使い分け
JotaiのHookは3種類あり、読みと書きのどちらに関心があるかで使い分けるのが鉄則です。書き込みしかしないコンポーネントがuseAtomを呼ぶと、書き込み機能と一緒に「値の購読」までしてしまい、不要な再レンダリングを引き起こします。
| Hook | 返り値 | 再レンダリング | 用途 |
|---|---|---|---|
useAtom(atom) |
[value, setValue] |
値変更で再レンダリング | 読み書き両方する |
useAtomValue(atom) |
value |
値変更で再レンダリング | 読みだけ |
useSetAtom(atom) |
setValue |
再レンダリングなし | 書きだけ(ボタンなど) |
Before/After: useAtomの誤用を分割で直す
// ❌ Before: 書き込みしかしないのにuseAtomで値も購読してしまう
import { useAtom } from "jotai";
import { counterAtom } from "./store";
function IncrementOnly() {
const [, setCount] = useAtom(counterAtom); // 値が変わるたび再レンダリング
return <button onClick={() => setCount((c) => c + 1)}>+1</button>;
}
// ✅ After: useSetAtomで購読を切り離す(クリックハンドラ純粋化)
import { useSetAtom } from "jotai";
import { counterAtom } from "./store";
function IncrementOnly() {
const setCount = useSetAtom(counterAtom);
// counterAtomがどれだけ変わってもこのコンポーネントは再レンダリングされない
return <button onClick={() => setCount((c) => c + 1)}>+1</button>;
}
atomFamily〜パラメータ付きatomの動的生成
「ID毎に状態を持ちたい」を解決するatomFamily
商品ID毎の在庫数、ユーザーID毎のオンライン状態、タブID毎のフォーム入力――こういう「キー毎に独立したatomが欲しい」場面で活躍するのがatomFamilyです。atomFamilyは「キーを受け取ってatomを返す関数」を生成し、同じキーには同じatomを返してくれます。useStateで配列を持って毎回detectするより、はるかに高速かつシンプルに書けます。
// atomFamilyで「商品ID毎のお気に入り状態」を表現
import { atom } from "jotai";
import { atomFamily } from "jotai/utils";
// (productId) => boolean atom を返すファミリー
export const favoriteAtomFamily = atomFamily((productId: string) =>
atom<boolean>(false)
);
// 利用側: productIdごとに独立したatomが手に入る
import { useAtom } from "jotai";
function FavoriteButton({ productId }: { productId: string }) {
const [isFav, setFav] = useAtom(favoriteAtomFamily(productId));
return (
<button onClick={() => setFav((v) => !v)}>
{isFav ? "★" : "☆"}
</button>
);
}
atomFamilyのキー比較とメモリ解放
atomFamilyは内部的にキーをマップで管理しているため、参照が残り続けるとメモリリークの原因になります。オブジェクトをキーにする場合は、第2引数に等価比較関数を渡してください。不要になったatomはfamily.remove(key)で明示的に解放できます。
// オブジェクトをキーにする場合の等価比較
import { atomFamily } from "jotai/utils";
import deepEqual from "fast-deep-equal";
type Coord = { x: number; y: number };
const tileAtomFamily = atomFamily(
(coord: Coord) => atom({ filled: false }),
deepEqual // 第2引数で等価比較を指定
);
// 不要になったキーは明示的に解放
tileAtomFamily.remove({ x: 1, y: 1 });
atomWithStorage〜永続化されたatom
localStorage / sessionStorage連携
テーマ設定、言語設定、ログイン情報のリメンバーなど、ページリロードしても保持したい状態はatomWithStorageを使うと一発で永続化できます。デフォルトではlocalStorageを使い、sessionStorageやカスタムストレージにも切り替えられます。
// localStorage連携atom
import { atomWithStorage } from "jotai/utils";
type Theme = "light" | "dark" | "system";
// ("ストレージキー", 初期値) で定義
export const themeAtom = atomWithStorage<Theme>("app:theme", "system");
// 利用側はatomと全く同じインターフェース
import { useAtom } from "jotai";
function ThemeSelect() {
const [theme, setTheme] = useAtom(themeAtom);
return (
<select value={theme} onChange={(e) => setTheme(e.target.value as Theme)}>
<option value="light">ライト</option>
<option value="dark">ダーク</option>
<option value="system">システム連動</option>
</select>
);
}
sessionStorageや独自ストレージへの切り替え
// sessionStorageを使う場合
import { atomWithStorage, createJSONStorage } from "jotai/utils";
const draftAtom = atomWithStorage(
"app:draft",
"",
createJSONStorage(() => sessionStorage)
);
// Cookieを使うカスタムストレージ(SSR対応の例)
import { atomWithStorage } from "jotai/utils";
const cookieStorage = {
getItem: (key: string) => {
if (typeof document === "undefined") return null;
const match = document.cookie.match(new RegExp(`${key}=([^;]+)`));
return match ? decodeURIComponent(match[1]) : null;
},
setItem: (key: string, value: string) => {
document.cookie = `${key}=${encodeURIComponent(value)}; path=/`;
},
removeItem: (key: string) => {
document.cookie = `${key}=; path=/; max-age=0`;
},
};
const langAtom = atomWithStorage("lang", "ja", cookieStorage);
atomWithReducer・atomWithReset・atomWithDefault
jotai/utilsの代表的ユーティリティ早見表
これから扱う3つを含め、Jotaiのユーティリティを一覧で押さえておくと、設計時に迷いません。
| ユーティリティ | 役割 | 典型ユースケース |
|---|---|---|
atomFamily |
キー毎の動的atom生成 | 商品ID毎・行ID毎の状態 |
atomWithStorage |
localStorage/sessionStorage連携 | テーマ・言語・下書き保存 |
atomWithReducer |
reducerをatom化 | 複雑な状態遷移 |
atomWithReset |
初期値リセット可能なatom | フォームクリア |
atomWithDefault |
派生で初期値を計算 | フォールバック値 |
loadable |
async atomを通常atom化 | Suspense非対応箇所 |
selectAtom |
オブジェクトの一部だけ購読 | 巨大state の部分監視 |
splitAtom |
配列を要素毎のatomに分解 | 長いリストの編集 |
useAtomCallback |
購読なしで値を取り出す | クリック時の最新値参照 |
atomWithReducerでReduxパターンを表現
状態遷移が複雑なときは、atomWithReducerを使うとuseReducer的なロジックをatom単位で表現できます。useReducer完全ガイドでreducerの基本を理解した上で、状態をモジュールスコープに切り出したい場面で重宝します。
// atomWithReducerでカートのreducerをatom化
import { atomWithReducer } from "jotai/utils";
type CartState = { items: { id: string; qty: number }[] };
type CartAction =
| { type: "add"; id: string }
| { type: "remove"; id: string }
| { type: "clear" };
const cartReducer = (state: CartState, action: CartAction): CartState => {
switch (action.type) {
case "add": {
const found = state.items.find((i) => i.id === action.id);
return found
? { items: state.items.map((i) => i.id === action.id ? { ...i, qty: i.qty + 1 } : i) }
: { items: [...state.items, { id: action.id, qty: 1 }] };
}
case "remove":
return { items: state.items.filter((i) => i.id !== action.id) };
case "clear":
return { items: [] };
}
};
export const cartAtom = atomWithReducer({ items: [] }, cartReducer);
// 利用側
import { useAtom } from "jotai";
function AddToCartButton({ id }: { id: string }) {
const [, dispatch] = useAtom(cartAtom);
return <button onClick={() => dispatch({ type: "add", id })}>追加</button>;
}
atomWithResetで「初期値に戻す」を実装
// atomWithReset + RESET で初期値に戻す
import { atomWithReset, useResetAtom } from "jotai/utils";
const draftAtom = atomWithReset({ title: "", body: "" });
function DraftForm() {
const reset = useResetAtom(draftAtom);
return <button onClick={reset}>入力をクリア</button>;
}
atomWithDefault〜デフォルト値を派生で持たせる
// atomWithDefault: デフォルト値を他のatomから計算
import { atomWithDefault } from "jotai/utils";
import { atom } from "jotai";
const userAtom = atom({ name: "山田太郎", nickname: "" });
// nicknameが未設定ならname、設定済みならnickname
const displayNameAtom = atomWithDefault((get) => {
const u = get(userAtom);
return u.nickname || u.name;
});
非同期atom・Suspense・loadable
async atomの基本
Jotaiは非同期処理を1級市民として扱うのが大きな魅力です。read関数をasyncにするだけで、そのatomはPromise<T>を持つasync atomになります。コンポーネント側でuseAtomValueすると、Suspenseと組み合わせて自動的にローディングUIに切り替わります。
// async atom: ユーザー情報をAPIから取得
import { atom } from "jotai";
type User = { id: string; name: string };
const userIdAtom = atom<string>("u1");
export const userAtom = atom(async (get) => {
const userId = get(userIdAtom);
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error("user fetch failed");
return (await res.json()) as User;
});
SuspenseとErrorBoundaryで包む
// async atomを使うコンポーネントはSuspense/EBで包む
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useAtomValue } from "jotai";
import { userAtom } from "./store";
function UserCard() {
// 読み取り時、Promiseが解決されるまでSuspendされる
const user = useAtomValue(userAtom);
return <div>{user.name}</div>;
}
export function UserPage() {
return (
<ErrorBoundary fallback={<div>エラーが発生しました</div>}>
<Suspense fallback={<div>読み込み中...</div>}>
<UserCard />
</Suspense>
</ErrorBoundary>
);
}
loadableで「Suspendさせずに状態を取り出す」
Suspenseに任せず、自分でローディング・エラー・成功を分岐したい場面ではloadableユーティリティを使います。loadable(asyncAtom)は、async atomを「state: loading | hasData | hasError」を持つ通常のatomへラップしてくれます。
// loadableでSuspendさせずに状態を扱う
import { atom } from "jotai";
import { loadable } from "jotai/utils";
import { useAtomValue } from "jotai";
const asyncUserAtom = atom(async () => {
const r = await fetch("/api/me");
return r.json() as Promise<{ name: string }>;
});
const userLoadableAtom = loadable(asyncUserAtom);
export function MeBadge() {
const result = useAtomValue(userLoadableAtom);
if (result.state === "loading") return <span>...</span>;
if (result.state === "hasError") return <span>エラー</span>;
return <span>{result.data.name}</span>;
}
refresh atomで「再フェッチ」を表現
// refresh用のatomを別に用意して、再フェッチをトリガーする
import { atom } from "jotai";
const refreshAtom = atom(0);
const postsAtom = atom(async (get) => {
get(refreshAtom); // 依存させることで refresh++ で再評価
const r = await fetch("/api/posts");
return r.json();
});
// 利用側
import { useSetAtom } from "jotai";
function ReloadButton() {
const refresh = useSetAtom(refreshAtom);
return <button onClick={() => refresh((n) => n + 1)}>再読込</button>;
}
useAtomCallbackとatomEffects
useAtomCallbackでatomの値をイベント時に取り出す
useAtomCallbackは、「ボタンを押した瞬間にatomの最新値を読み取って何かする」ようなケースで使います。useAtomValueを使うと値の購読が発生し再レンダリングしますが、useAtomCallbackはコールバック内でのみ値を読み取るため、購読は発生しません。
// useAtomCallback: 購読せずに値を取り出す
import { useCallback } from "react";
import { atom, useAtomValue } from "jotai";
import { useAtomCallback } from "jotai/utils";
const draftAtom = atom("");
function SaveButton() {
const save = useAtomCallback(
useCallback((get) => {
const draft = get(draftAtom);
console.log("保存:", draft);
// ここでAPIを叩くなどの副作用
}, [])
);
return <button onClick={save}>保存</button>;
}
Provider Scope〜複数ストアの分離
ProviderでサブツリーごとにストアをスコープしてあるWidgetを再利用する
Jotaiは複数のProviderをネストできて、それぞれが独立したストアを持ちます。これは「同じコンポーネントを別データで動かしたい」場面で強力です。たとえば「商品比較ページ」で、左右に同じ詳細パネルを置きつつ、それぞれ別の商品データを持たせるといった構成が、コンポーネント側に変更を加えずに実現できます。
// Providerをネストして、サブツリーごとにストアを分離
import { Provider, atom, useAtom } from "jotai";
const titleAtom = atom("default");
function Panel() {
const [title, setTitle] = useAtom(titleAtom);
return (
<div>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
);
}
export function ComparePage() {
return (
<div style={{ display: "flex", gap: 16 }}>
{/* 左パネル: 自分のストアを持つ */}
<Provider>
<Panel />
</Provider>
{/* 右パネル: 別のストアを持つ(左と独立) */}
<Provider>
<Panel />
</Provider>
</div>
);
}
createStoreで明示的にストアを操作する
// createStoreでReactの外からatomを読み書き
import { createStore, Provider, atom, useAtom } from "jotai";
const myStore = createStore();
const counterAtom = atom(0);
// Reactの外で初期値を仕込んでおく
myStore.set(counterAtom, 100);
export function App() {
return (
<Provider store={myStore}>
<Counter />
</Provider>
);
}
function Counter() {
const [c, setC] = useAtom(counterAtom);
return <button onClick={() => setC(c + 1)}>{c}</button>;
}
パフォーマンス最適化〜selectAtomとsplitAtom
selectAtomで「オブジェクトの一部だけ」を購読する
巨大なオブジェクトを持つatomがあるとき、特定のフィールドだけ変化したら再レンダリングしたい――そんな要望に応えるのがselectAtomです。ZustandのuseStore(selector)と同じ発想で、必要な値だけを選択して購読できます。
// selectAtom: 大きなオブジェクトから一部だけ購読
import { atom } from "jotai";
import { selectAtom } from "jotai/utils";
type Settings = {
theme: "light" | "dark";
language: "ja" | "en";
notifications: { email: boolean; push: boolean };
};
const settingsAtom = atom<Settings>({
theme: "light",
language: "ja",
notifications: { email: true, push: false },
});
// themeだけを購読(他フィールドの変更では再レンダリングしない)
const themeAtom = selectAtom(settingsAtom, (s) => s.theme);
// notifications.emailだけ購読
const emailNotifAtom = selectAtom(
settingsAtom,
(s) => s.notifications.email
);
splitAtomで配列atomを「行毎のatom」に分解
splitAtomは、配列を持つatomを「要素毎の独立したatomの配列」に分解するユーティリティです。長いリストで「1行編集したら全行再レンダリング」を防ぐ最高の武器です。
// splitAtom: 配列を要素毎のatomに分解
import { atom, useAtom } from "jotai";
import { splitAtom } from "jotai/utils";
type Todo = { id: string; title: string; done: boolean };
const todosAtom = atom<Todo[]>([
{ id: "1", title: "牛乳", done: false },
{ id: "2", title: "卵", done: false },
]);
// 各要素ごとのatomを持つ配列atom
const todoAtomsAtom = splitAtom(todosAtom);
function TodoList() {
const [todoAtoms] = useAtom(todoAtomsAtom);
return (
<ul>
{todoAtoms.map((todoAtom) => (
<TodoItem key={`${todoAtom}`} todoAtom={todoAtom} />
))}
</ul>
);
}
function TodoItem({ todoAtom }: { todoAtom: any }) {
// この1要素atomだけを購読 → 他要素の変更では再レンダリングしない
const [todo, setTodo] = useAtom(todoAtom);
return (
<li>
<input
type="checkbox"
checked={todo.done}
onChange={(e) => setTodo({ ...todo, done: e.target.checked })}
/>
{todo.title}
</li>
);
}
jotai-tanstack-query連携〜サーバー状態をatom化
クライアント状態とサーバー状態を1つの世界観に統合する
TanStack Queryはサーバー状態のキャッシュ・再フェッチ・楽観的更新の決定版ですが、クライアント状態(モーダル開閉等)はJotaiで扱いたい、という構成は非常に多いです。jotai-tanstack-queryを使うと、TanStack Queryのクエリ・ミューテーションをatomとして扱えるようになり、両者を統一的に書けます。
// jotai-tanstack-query: クエリをatom化
import { atomWithQuery } from "jotai-tanstack-query";
type Post = { id: string; title: string };
export const postsAtom = atomWithQuery<Post[]>(() => ({
queryKey: ["posts"],
queryFn: async () => {
const r = await fetch("/api/posts");
return r.json();
},
staleTime: 60_000,
}));
// 利用側
import { useAtomValue } from "jotai";
function PostList() {
const { data, isPending } = useAtomValue(postsAtom);
if (isPending) return <p>loading</p>;
return (
<ul>{data?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
);
}
// atomWithMutation: ミューテーションもatom化
import { atomWithMutation } from "jotai-tanstack-query";
import { useAtom } from "jotai";
const createPostAtom = atomWithMutation(() => ({
mutationFn: async (title: string) => {
const r = await fetch("/api/posts", {
method: "POST",
body: JSON.stringify({ title }),
});
return r.json();
},
}));
function NewPostButton() {
const [{ mutate, isPending }] = useAtom(createPostAtom);
return (
<button disabled={isPending} onClick={() => mutate("新しい記事")}>
作成
</button>
);
}
jotai-immer〜複雑な更新をmutableに書く
ネストしたオブジェクトの更新が一気に楽になる
深くネストしたstateを...spreadでコピーして更新するのは骨が折れます。jotai-immerを使うと、Immer同様「見た目はmutableに書くが内部は不変更新」ができ、ネスト更新が劇的に楽になります。
// jotai-immer: atomWithImmerでmutable風に更新
import { atomWithImmer } from "jotai-immer";
type State = {
user: { profile: { name: string; bio: string }; tags: string[] };
};
const stateAtom = atomWithImmer<State>({
user: { profile: { name: "山田", bio: "" }, tags: [] },
});
// 利用側
import { useAtom } from "jotai";
function BioEditor() {
const [state, setState] = useAtom(stateAtom);
return (
<textarea
value={state.user.profile.bio}
onChange={(e) =>
setState((draft) => {
// 直接代入してOK(内部でImmerが不変更新に変換)
draft.user.profile.bio = e.target.value;
})
}
/>
);
}
Devtools〜デバッグを快適にする
jotai-devtoolsでatomの動きを可視化
Redux DevToolsと同じ感覚でJotaiのatomを観察したいなら、jotai-devtoolsを入れて<DevTools />を配置するだけです。タイムトラベルこそありませんが、atomごとの値変化が可視化され、デバッグ効率が大きく向上します。
// jotai-devtools の最小セットアップ
import { DevTools } from "jotai-devtools";
import "jotai-devtools/styles.css";
export function App() {
return (
<>
<DevTools />
<MainApp />
</>
);
}
// atomに名前をつけてDevToolsで見やすくする
import { atom } from "jotai";
const counterAtom = atom(0);
counterAtom.debugLabel = "counterAtom"; // DevToolsで識別しやすくなる
const userAtom = atom({ name: "" });
userAtom.debugLabel = "userAtom";
TypeScript型注釈の実践パターン
型推論を活かす書き方
TypeScriptと相性が極めて良いのもJotaiの強みです。atom<T>(initial)でジェネリクスを指定すれば、useAtomの返り値も自動で正しく型付けされます。
// 型推論を活かすパターン集
import { atom, useAtom } from "jotai";
// 1) プリミティブ: 初期値から自動推論
const count = atom(0); // atom<number>
// 2) Union型: 明示的にジェネリクス
type Status = "idle" | "loading" | "success" | "error";
const status = atom<Status>("idle");
// 3) オブジェクト: 型を宣言してから渡す
type User = { id: string; name: string };
const user = atom<User | null>(null);
// 4) read-write atomの引数型
const nameAtom = atom("");
const upperName = atom(
(get) => get(nameAtom).toUpperCase(),
(_get, set, newName: string) => set(nameAtom, newName.toLowerCase())
);
// 5) action型のwrite-only atom
type Action = { type: "inc" } | { type: "set"; value: number };
const dispatchAtom = atom(null, (get, set, action: Action) => {
if (action.type === "inc") set(count, get(count) + 1);
if (action.type === "set") set(count, action.value);
});
テスト戦略〜atomを単独でテストする
createStoreでロジックを単体テスト
atomはモジュールスコープに置かれた純粋な値なので、ReactレンダリングなしでロジックをUnit Testできます。createStoreで隔離したストアを作り、get/setで操作するだけです。
// atomの単体テスト(Vitest想定)
import { describe, it, expect } from "vitest";
import { createStore } from "jotai";
import { todosAtom, remainingCountAtom } from "./store";
describe("remainingCountAtom", () => {
it("未完了タスク数を正しく計算する", () => {
const store = createStore();
store.set(todosAtom, [
{ id: "1", title: "a", done: false },
{ id: "2", title: "b", done: true },
{ id: "3", title: "c", done: false },
]);
expect(store.get(remainingCountAtom)).toBe(2);
});
});
採用判断と他ライブラリとの比較
Jotaiが特に向くシーン
| シーン | Jotaiが向く理由 |
|---|---|
| 細粒度の購読が必要 | atom単位で配信先が分かれる・selectAtom/splitAtomで更に分解可 |
| 派生値が多い | read関数で依存グラフが自動構築・selectorを書く必要なし |
| 非同期処理が多い | async atom + Suspenseで宣言的に書ける |
| 段階的に導入したい | Providerなしで動く・useStateと共存可 |
| RecoilからのMigration | API思想が近く移行コストが低い |
Zustandと比べたときの選び方
シェアの大きいZustandと比べると、Zustandは「単一ストア・selector駆動」、Jotaiは「分散atom・派生駆動」と思想が真逆に近いです。シンプルさで言えばZustand、宣言的な派生・非同期の表現力で言えばJotaiが優位です。チームでReduxパターンに慣れている人が多いならZustand、Reactらしい関数合成・宣言的UIを徹底したいならJotaiが合います。
よくある質問(FAQ)
Q1. Jotaiは小さなプロジェクトでも導入する価値がありますか?
はい、特にuseContext+useStateで状態を持ち回している規模で、すでにJotaiを入れる価値があります。useContext完全ガイドで触れたように、Contextの「value変化で全購読者再レンダリング」問題は小規模でも発生します。Jotaiは3KBと軽量・Providerなしで動く・useStateと共存可能なので、フルマイグレーションせず「再レンダリングが痛い箇所だけ置き換える」ことができます。
Q2. async atomとloadable、どちらを使うべきですか?
Suspense/ErrorBoundaryで包む構成にできるならasync atomをそのまま使い、宣言的に書くのがJotaiらしい設計です。古いコードベースでSuspenseを導入しづらい・部分的にローディングUIを出し分けたい場合はloadableを選びます。両者は同じasync atomから派生させられるため、画面によって使い分けることも可能です。
Q3. atomFamilyを使うときメモリリークが心配です
キーに同一参照のプリミティブ(文字列・数値)を使う場合は問題が起きにくいですが、毎レンダリングで新しいオブジェクトを生成してキーにすると、未使用atomがマップに溜まり続けます。対策は2つあり、(1) 第2引数に等価比較関数(fast-deep-equal等)を渡してキー再利用を促す、(2) アンマウント時にfamily.remove(key)で明示的に解放、を組み合わせるのが現場での定番です。
Q4. SSR(Next.js)でJotaiを使うとき注意点は?
もっとも重要なのは「リクエストごとにストアを分離する」ことです。デフォルトストアはモジュールスコープのため、サーバーで複数リクエストが状態を共有してしまうリスクがあります。Next.jsのApp Routerでは、各リクエストの境界でcreateStore()+Providerでラップし、リクエスト間のリークを防ぎます。atomWithStorageはwindowに依存するため、SSR時はtypeof window === "undefined"で分岐するか、専用のSSRセーフなストレージを渡してください。
Q5. ReduxからJotaiに移行するときの進め方は?
一括移行は危険なので、「新規ドメインだけJotai」「Redux領域はそのまま」の共存運用から始めます。React ReduxのProviderはそのまま、JotaiのProviderも置いて2系統を同居させ、新しい機能だけJotai側に書いていきます。サーバー状態はこのタイミングでjotai-tanstack-queryに切り出し、純粋なクライアント状態だけ徐々にJotaiへ移すのが、痛みの少ない移行ルートです。
Q6. JotaiのDevToolsはRedux DevToolsほど高機能ではない印象です
事実、タイムトラベルやアクション履歴の豊富さはRedux DevToolsが上です。ただJotai DevToolsは「atomの依存グラフと値変化」を見るのに最適化されており、Jotaiの設計思想とは噛み合っています。デバッグの主軸はdebugLabelを全atomに付けてDevToolsで識別性を高めること・複雑な派生はconsole.log付きの一時atomで挙動を確認することの2点でほぼ完結します。
Q7. Recoilからの移行コストはどのくらいですか?
API名・思想ともに近く、1〜2日でコア機能の置き換えが完了する規模感のプロジェクトが多いです。RecoilのatomはJotaiのatom、selectorは派生atom、atomFamilyはそのままjotai/utilsのatomFamilyに対応します。違いとして、RecoilのRecoilRootは必須でしたが、JotaiのProviderは任意なので置き換えの自由度が高いです。注意点はselector内で複雑なparam依存をしていたケースで、JotaiではatomFamilyやselectAtomで再構成する必要があります。
まとめ〜Jotaiは「Reactらしい状態管理」の現代解
本記事ではJotai 使い方を、基本atomからatom utilities(atomFamily/atomWithStorage/atomWithReducer/atomWithReset/atomWithDefault/loadable/selectAtom/splitAtom)、async atomとSuspense連携、jotai-tanstack-query・jotai-immer・jotai-devtoolsの活用まで一気通貫で解説しました。Jotaiの本質は「Reactのコンポーネントモデルと同じ流儀(宣言的・関数合成・依存自動追跡)で状態を分散管理する」ことにあります。Reduxのように全体構造を先に決めなくても、Zustandのように単一ストアで設計しなくても、必要なatomを必要な場所で定義して合成するだけで、再レンダリング最小化・型安全・テスト容易性を全て両立できます。React状態管理ライブラリ完全比較で各候補を俯瞰した上で、Jotaiが思想に合うと感じたら、まずは小さな機能でatom1つから導入してみてください。Reactらしい設計の延長として、自然に手に馴染むはずです。

コメント