状態管理は、2025年の今なおReactアプリ開発における最も頭を悩ませる要素のひとつです。アプリが大規模化し、UIがますます動的になるにつれ、開発者は状態の同期、過剰な再レンダリングの回避、ボイラープレートの削減などの課題に日々立ち向かっています。最近の調査では、React開発者が同期問題、パフォーマンス、そしてReduxのようなソリューションの複雑さを上位の痛点として挙げていました。React標準のContext APIやHooksは基本的なユースケースをカバーしますが、「中規模以上」のアプリや複雑な状態管理には十分でないと感じるチームが多いのが現状です。
その結果、エコシステムは状態管理ライブラリの氾濫状態にあります。2024年までに、よりシンプルでエルゴノミクスに優れた軽量ライブラリが急速に支持を集めました。中でも、ZustandとJotaiはReduxの複雑さからの解放を求める開発者たちの間で二大勢力として頭角を現しています。ZustandはミニマルなAPIと「驚くほどシンプルな開発体験」が評価され、急速に人気を伸ばしています。一方でJotaiは、原子(Atom)ベースのモデルと細粒度のリアクティビティという新機軸で、着実に採用を増やしています。
しかし、選択肢が増えたことで、React開発者は依然として最適解を見つけるのに苦労しています。**状態を一元的に管理すべきか、それとも多くの原子に分割すべきか?**どちらのライブラリが大規模なチームやコードベースに向いているのか?そして、AI支援コーディングの時代にはどちらがより親和性が高いのか?この記事では、ZustandとJotaiをメンタルモデルやコード例、パフォーマンス、スケーラビリティ、開発者体験の観点から徹底比較し、2025年のプロジェクトに最適な状態管理ツールを見極める手助けをします。
哲学の違い:トップダウン vs ボトムアップの状態管理
コードに入る前に、ZustandとJotaiがどのように哲学を異にしているかを押さえておきましょう。
-
Zustand(ドイツ語で「状態」を意味)は、中央集権的なトップダウンモデルを採用しています。2019年に登場したこのライブラリは、よりシンプルなReduxの代替として設計されました。Zustandでは全ての状態を1つ(または複数)のグローバルなストアに収め、Fluxライクな原則に従って、明示的なアクションを通じて状態を更新します。たとえ複数のストアを作っても、それぞれが予測可能な状態とアップデーター関数の集合となるため、構造が明確です。Redux出身の開発者にとっては、シングルストアのメンタルモデルが馴染み深く、採用しやすいのが魅力です。
-
Jotai(日本語で「状態」を意味)は、原子(Atom)ベースのボトムアップアプローチを取ります。2020年にリリースされ、FacebookのRecoilに触発されたJotaiでは、小さく独立したアトムを多数作成し、各アトムが
useState
に似た単一の値としてコンポーネント間で共有されます。単一のストアは存在せず、コンポーネントは必要なアトムだけを購読します。このモデルはまるでレゴブロックのように、状態を細かいピースから組み立てる柔軟性を提供し、React標準のフック感覚に非常に近いと感じる開発者も多いです。
まとめると、Zustand=一本の大きな状態ツリー、Jotai=たくさんの小さな状態アトムというイメージです。どちらも最終的に同じような機能を実現できますが、メンタルモデルの切り替えが大きなポイントになります。Redditのユーザーも次のように述べています。
“Zustandはグローバルストア寄り、Jotaiは個々のアトム寄り。機能的にはほぼ同じことができるけど、どちらを選ぶかはメンタルモデル次第だよね。”
どちらのアプローチが「絶対に優れている」というわけではなく、データの整理方法や更新の捉え方によって使い分けるのが吉です。次章では、実際のコード例を通じてそれぞれの使い勝手を見ていきましょう。
コード例:グローバルステート、ローカルリアクティビティ、非同期ロジック
比較を具体化するために、ZustandとJotaiそれぞれで典型的な状態管理タスクをどのように扱うか、サイドバイサイドで示します。以下の例では、グローバルステートの共有、局所的なリアクティビティ、そして非同期更新を各ライブラリでどのように実装するかを解説します。
例1:グローバルステートのカウンター
複数のコンポーネントが読み書きするシンプルなグローバルカウンターを想定します。
Zustandの場合:中央ストアとフック
Zustandでは、状態と更新関数を保持するストアをcreate
関数で定義し、コンポーネント内でフックを使ってアクセスします。
import { create } from 'zustand';
// 1. ストアを定義
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
// 2. コンポーネントでストアを利用
function CounterDisplay() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return <button onClick={increment}>Clicked {count} times</button>;
}
ここでは、count
という状態とincrement
というアクションを持つストアを作成しています。コンポーネントはuseCounterStore
を呼び出し、必要な値を個別にセレクトして受け取ります。これによりcount
に変更があったときだけ再レンダリングが発生し、他の状態が更新されても無駄な再描画が起きません。ZustandはContextプロバイダーを不要とし、useCounterStore
フックをアプリのどこでも使えます。
Jotaiの場合:アトムとuseAtomフック
Jotaiでは、カウントを保持するアトムを定義し、useAtom
フックでコンポーネントに取り込みます。
import { atom, useAtom } from 'jotai';
// 1. 状態用のアトムを定義
const countAtom = atom(0);
// (任意)派生状態用アトムの定義例
const doubleCountAtom = atom((get) => get(countAtom) * 2);
// 2. コンポーネントでアトムを利用
function CounterDisplay() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount((c) => c + 1)}>
Clicked {count} times
</button>;
}
見た目はまるでuseState
フックのようで、非常に習得しやすいのが特徴です。countAtom
はコンポーネント外で定義されるため状態はグローバルになり、どのコンポーネントでも同じ値を共有します。doubleCountAtom
を使えば、count
の変化に応じた派生データを取得できます(doubleCountAtom
を使うコンポーネントだけが更新され、countAtom
のみを使うコンポーネントには影響しません)。
ポイント: Zustandは状態とアクションを一箇所にまとめることでロジックを探しやすくし、Jotaiはアトムを散りばめることで柔軟に状態を分割・共有できます。単純なカウンター程度ならReactのローカル状態で十分ですが、大規模アプリではこれらのパターンが真価を発揮します。
例2:ローカルリアクティブステート(細粒度の更新)
ここでは、複数の独立した状態を扱うシナリオを考えます。たとえば、テキスト入力と別のカウンターが同じアプリ上にあり、それぞれ無関係だとします。ある状態の更新が他方のコンポーネントを再レンダリングしないようにしたい—すなわちローカルリアクティビティを実現します。
Zustandの場合、単一のストアを使いつつ、セレクターで各コンポーネントが必要なスライスのみを購読する手法が使えます。
import { create } from 'zustand';
// Zustandストアにテキストとカウントの2つの状態を定義
const useStore = create((set) => ({
text: "",
count: 0,
setText: (value) => set({ text: value }),
increment: () => set((state) => ({ count: state.count + 1 }))
}));
function TextInput() {
const text = useStore((s) => s.text);
const setText = useStore((s) => s.setText);
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
function CounterButton() {
const count = useStore((s) => s.count);
const increment = useStore((s) => s.increment);
return <button onClick={increment}>Count: {count}</button>;
}
ここでは TextInput
が text
と setText
のみを、CounterButton
が count
と increment
のみを購読しています。Zustandのセレクティブサブスクリプション機能により、テキスト更新時にカウンターが再レンダリングされず、その逆も同様です。単一ストアでも、状態の局所的な更新を実現できます。
Jotaiの場合、関心ごとごとにアトムを分けるだけで隔離が簡単に実現できます。
import { atom, useAtom } from 'jotai';
const textAtom = atom("");
const countAtom = atom(0);
function TextInput() {
const [text, setText] = useAtom(textAtom);
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
function CounterButton() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>;
}
Jotaiは初期状態から細粒度のリアクティビティを提供し、textAtom
の更新はtextAtom
を使うコンポーネントだけに影響します。異なるアトムを使うコンポーネントが不要な再レンダリングを起こすことはありません。
両ライブラリとも必要に応じてコンポーネントツリーの一部に状態をスコープする機能があります。ZustandはContextを使ってストアを限定的に提供でき、Jotaiは<Provider>
で特定のサブツリーにアトムを束ねられます(同じアトムを別々にインスタンス化したい場合などに便利です)。しかし多くの場合、Zustandのセレクティブ購読やJotaiのアトム分割だけで、状態更新を局所化できます。
例3:非同期ステートと副作用
状態管理では、静的な値だけでなく、データフェッチや複雑な副作用といった非同期更新を扱う必要がよくあります。ZustandとJotaiの両者とも、コンポーネントをシンプルに保ちつつ非同期ロジックを管理できます。
Zustandの場合:非同期ロジックを備えたアクション
Zustandでは、ストア内に直接非同期アクションを書くことができます。たとえば、グローバルなユーザーストアでユーザーデータを取得する例です。
import { create } from 'zustand';
import { useEffect } from 'react';
const useUserStore = create((set) => ({
user: null,
isLoading: false,
error: null,
fetchUser: async (id) => {
set({ isLoading: true, error: null });
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('ユーザーの取得に失敗しました');
const data = await res.json();
set({ user: data, isLoading: false });
} catch (err) {
const error = err instanceof Error ? err : new Error('不明なエラーが発生しました');
set({ error, isLoading: false });
}
}
}));
// コンポーネントで非同期アクションを利用
function Profile({ userId }) {
const user = useUserStore((state) => state.user);
const isLoading = useUserStore((state) => state.isLoading);
const error = useUserStore((state) => state.error);
const fetchUser = useUserStore((state) => state.fetchUser);
useEffect(() => {
if (userId) fetchUser(userId);
}, [userId, fetchUser]);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return user ? <div>Hi, {user.name}</div> : <div>User not found</div>;
}
ここでは、fetchUser
アクションがローディング状態の設定、データ取得、エラー処理といった副作用をまとめて行い、ストアを更新します。コンポーネント側は fetchUser
を呼び出すだけで、結果の状態を使うだけなのでとてもクリーンです。さらに、Zustandはアクション内での複数の set
をバッチ処理するため、user
と isLoading
を同時に更新しても一度しかレンダリングが起こりません。
Jotaiの場合:書き込み可能な派生アトムで非同期アクション
Jotaiでは、状態を複数のアトムに分け、非同期操作用に書き込み専用の派生アトムを定義するパターンが一般的です。同じユーザー取得例をJotaiで書くとこうなります。
import { atom, useAtom } from 'jotai';
import { useEffect } from 'react';
// 各状態用アトムを定義
const userAtom = atom(null);
const isLoadingAtom = atom(false);
const errorAtom = atom(null);
// 非同期処理を行う書き込み専用派生アトム
const fetchUserAtom = atom(
null,
async (get, set, userId) => {
set(isLoadingAtom, true);
set(errorAtom, null);
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('ユーザーの取得に失敗しました');
const data = await res.json();
set(userAtom, data);
} catch (err) {
const error = err instanceof Error ? err : new Error('不明なエラーが発生しました');
set(errorAtom, error);
} finally {
set(isLoadingAtom, false); // 成功・失敗に関わらずローディングを終了
}
}
);
// 表示用のデータをまとめる派生アトム (コンポーネント外で定義)
const profileDataAtom = atom((get) => ({
user: get(userAtom),
isLoading: get(isLoadingAtom),
error: get(errorAtom)
}));
// コンポーネントでアトムを利用
function Profile({ userId }) {
const [{ user, isLoading, error }] = useAtom(profileDataAtom);
const [, fetchUser] = useAtom(fetchUserAtom);
useEffect(() => {
if (userId) fetchUser(userId);
}, [userId, fetchUser]);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return user ? <div>Hi, {user.name}</div> : <div>User not found</div>;
}
このパターンでは、userAtom
や isLoadingAtom
といった状態アトムと、fetchUserAtom
という書き込み専用アトムを分けて定義します。fetchUserAtom
自身は状態を保持せず、書き込み関数として他のアトムを書き換えます。JotaiはReact Suspenseとも連携しやすい設計なので、より滑らかなローディング体験を組み込むことも可能です。Zustand同様、コンポーネントはアクション(ここでは fetchUser
)を呼び出し、状態アトムを参照するだけで非同期処理を扱えます。
例のまとめ: Zustandはロジックをストア内にまとめる傾向があり、一方Jotaiは専用のアトムにロジックを分割することを推奨します。Reduxや中央集権的ストアを使った経験があるなら、Zustandのアプローチはより馴染み深く感じるでしょう(一箇所に関連ロジックが集約されているため)。JotaiのアプローチはReactのパラダイムにより近く、小さな状態とアップデーター関数が多数あるイメージです。どちらも非同期フローや派生状態(Jotaiは他のアトムから計算可能、Zustandはセレクターやミドルウェアで導出可能)、さらには永続化(どちらも専用ユーティリティあり)といった高度なユースケースに対応できます。次に、これらの選択がパフォーマンスと開発者体験にどのように影響するかを比較してみましょう。
パフォーマンスとスケーラビリティ
ZustandもJotaiも、APIやバンドルサイズの点でReduxより軽量かつ高性能な状態管理ライブラリです。ただし、アプリの状態規模や複雑さが増すにつれて、パフォーマンス特性に微妙な違いが現れます。
-
バンドルサイズ
- Zustandのコアは約2.8KB(gzip後)
- Jotaiのコアは約3.5KB(gzip後。ドキュメントによっては約4KBと記載)
実際のところ、どちらもアプリにとってごく小さな追加です。
-
再レンダリングと細粒度の更新
- Zustand はセレクティブサブスクリプションを採用し、コンポーネントは選択した状態の部分が変わったときだけ再レンダリングされます。
-
Jotai はアトム単位のリアクティビティを提供し、特定のアトムを読んでいるコンポーネントだけが、そのアトム変更時に再レンダリングされます。
どちらも無駄なレンダリングを防ぐ仕組みですが、頻繁に細かく変わる状態にはJotai、大きな状態をまとめて変更するケースにはZustand が強みを発揮します。
-
メモリとガベージコレクション
- Zustand:一つのオブジェクトツリーで状態を保持
-
Jotai:小さなアトムオブジェクトを多数保持
数百個のアトムなら問題ありませんが、極端に大量のアトムを使うと内部管理に若干のオーバーヘッドが出る可能性があります。逆に、Zustandで巨大かつ深くネストした状態を扱う場合は、データを正規化するなどの工夫が必要になることも。どちらも「適切に使えば大規模アプリを支えられる」設計ですが、チーム開発での保守性という観点ではZustandの中央集権的アプローチがやや有利かもしれません。
-
Concurrency(並行レンダリング)と「Tearing」
React 18の並行レンダリングでは、UIの「ティアリング」(コンポーネント間で不整合な状態を参照してしまう現象)が問題になります。- Zustandは内部で
useSyncExternalStore
を活用し、安定した互換性を確保。 - Jotaiも外部ストアとして動作しますが、デフォルトで
useSyncExternalStore
を使わない設計上の理由があり、独自にティアリング対策を施しています。
どちらも通常利用では一貫性のある状態をレンダリングできますが、SuspenseやトランジションAPIを多用する場合、JotaiのビルトインSuspenseサポートがわずかなアドバンテージになることがあります。
- Zustandは内部で
-
拡張性とエコシステム
両者ともミドルウェアやユーティリティのエコシステムが充実しています。- Zustandは公式の開発者ツール連携、永続化、ストア分割などのミドルウェアを提供し、ログ出力や不要な更新抑制などでパフォーマンスを強化できます。
- JotaiはReact Queryとの統合や、JSON永続化、URL同期などのユーティリティアトム群が成長中です。
パフォーマンス面で制約を感じたら、両者を使い分けたり、最終的にカスタムストアを構築したりすることも可能です。実際、50万行規模の大規模アプリでは、Redux→Zustand→Jotaiと試した末に、さらに細粒度のカスタムソリューションを採用したチームもいました。このような例は稀ですが、ZustandもJotaiも十分にスケールするものの、無限ではないことを示しています。
要約すると、通常の利用範囲でパフォーマンスのボトルネックに悩むことはほとんどありません。多くの開発者がZustandを「高度に最適化されている」と評価し、Jotaiを「原子単位の更新に強い」と評価しています。次章では、状態の保守性にどう影響するかを見ていきましょう。
開発者体験と保守性
パフォーマンスも大事ですが、日々使ったときの感触やチーム規模での拡張性も同じくらい重要です。以下では、ZustandとJotaiの **DX(開発者体験)**および保守性の観点から比較します。
-
習得曲線とAPIの使いやすさ
両者とも学習コストは低く、React Hooksの知識があればすぐに扱えます。JotaiのAPIはuseState
やuseReducer
にほぼそのまま似ており、アトムを定義してuseAtom
で[value, setValue]
を受け取る流れは直感的です。「メンタルモデルとしてuseState
に最も近い」と評されることが多く、React開発者には取り付きやすいでしょう。Zustandも状態と関数を一度に定義するスタイルでボイラープレートが少なく、「余計な手順がなくて助かる」という声が多数。どちらも不要なReduxのアクション定義やリデューサーを排し、プレーンなReactパラダイムで書けるためDXは高評価です。 -
コードの構造と整理
ここが大きな分かれ目になります。Zustandはストアという明確な構造を強制するため、状態ロジックの「入り口」が一箇所にまとまります。大規模アプリでは「どこを見れば状態が更新されるか」が全員に共有されるため、保守性が向上します。あるエンジニアは「Zustandはストアに構造を強いる…結果的にチームでのメンテが楽になる」と語っています。一方、Jotaiは自由度が極めて高く、アトムをどこにでも置ける反面、整理のルールをチームで決めないと大量のアトムが散在し追いにくくなる危険があります。実際、13人規模のチームで「アトムが増えすぎてプロジェクト全体が追いづらくなった」ため方針を転換した例も。Jotaiを使う際は、関連アトムをまとめる、命名規則を統一する、コンポーネントやドメイン単位で共置するといった規律が不可欠です。Zustandは設計上、こうした構造を最初から提供してくれるため、保守面でのメリットがあります。 -
チームでの協働
複数人で開発する場合、中央ストアは自然なドキュメント代わりになります。「状態はuseStore
にある」と全員が認識できる点が強みです。アトム単位だと「このアトムはどこで使う?」「どのUI範囲?」といった運用ルールが必要になります。どちらも運用次第で克服できますが、手数の多い大規模チームではZustandの予測可能性が精神的負荷を下げる傾向があります。実際に「グローバル状態はZustand、局所的なインタラクションはJotai」と両方を使い分けるハイブリッド戦略を採るチームも存在します。 -
DevToolsとデバッグ
Redux DevToolsの「タイムトラベルデバッグ」や「アクションログ」は強力な基準を示しました。Zustandはミドルウェア経由でRedux DevToolsに対応し、ブラウザ拡張で状態の遷移を追えます。Jotaiはグローバルなアクションログを持たない設計ですが、状態観察用のAPIとコミュニティ製のDevToolsがあり、ややシンプルな印象です。アプリ全体のスナップショットや全アクションの記録が必須ならZustand(あるいはRedux)のほうが安心感がありますが、多くの開発者は両者ともコンソールログやReact Developer Toolsで十分デバッグできています。 -
TypeScriptサポート
両ライブラリともTypeScriptで記述され、タイプ定義も充実しています。Zustandはストアの型インターフェイスを設定するとフックの型推論が働き、存在しないアクション呼び出しなどをコンパイル時に検出できます。Jotaiもアトムの値や派生アトムの入出力型を厳密に定義でき、どちらも補完や型チェックが非常に快適です。 -
コミュニティとリソース
ZustandはGitHubで53k+スターを誇り、導入事例やブログ記事が豊富です。Jotaiも約20kスターと勢いがあり、エレガントさを愛好する熱心なコミュニティがあります。Zustandのほうが先行していた分フォーラムで目にする機会はやや多いものの、両ライブラリともpmndrs組織のもと活発にメンテナンスされており、2025年も作者(加藤大志氏)やコミュニティによるアップデートが続いています。 -
AI支援開発
2025年においてはGitHub CopilotやChatGPTといったAIペアプログラマーが当たり前になりつつあります。AIにコード生成を頼む際、パターンの一貫性や可読性が重要です。Zustandのように「状態とアクションがまとめてケアされる明確なストア」は、AIが参照・更新対象を特定しやすい利点があります。Jotaiは柔軟ですが、「どのアトム?」という文脈把握が必要で、命名や配置ルールが曖昧だとAIが重複したアトムを生んだりミスを誘発したりすることもあります。AIと協調開発するなら、いずれのライブラリでも状態定義の規約を明文化しておくことが効果的です。Zustandは人気パターンとしてAIトレーニングデータにも多く含まれるため、AIのコード生成品質がやや向上しやすいという声もありますが、最終的にはプロジェクト内での一貫した運用が鍵となります。
推奨:2025年にはどちらを選ぶべきか?
ZustandもJotaiもReactの状態管理において優れた選択肢であり、用途に応じて両者を併用するのも有効です。しかし、2025年のデフォルトとしてどちらか一つを薦めるなら、ほとんどの開発者とプロジェクトにはZustandを推奨します。
なぜZustandか? シンプルさと構造化、コミュニティの勢いのバランスが秀逸だからです。中央集権的なアプローチにより、大規模アプリやチームでも複雑な状態を把握しやすくなります。多くの開発者が「使ってみてポジティブな体験だった」と報告しています。また、GitHubスター数はJotaiの倍以上で、学習リソースが豊富なうえ、AI支援時にも質の高いコードサンプルを得やすいという利点があります。ある調査では「ZustandはReactにおける定番の軽量ソリューションとして急速に支持を集めている」と評価されています。実際、Zustandを選ぶことで、2025年現在広く知られ、サポートされているパスを辿ることができます。
Jotaiが向くケースは?
- 独立した小さな状態が多数ある高度にインタラクティブなUI
- 多数のフィールドを扱う複雑なフォーム
- 「useState感覚を保ちつつグローバルに共有したい」という場合
Jotaiの細粒度な更新やアトムの柔軟な組み合わせは強力です。分散型の管理が好みで、チーム内でアトム設計のルールをしっかり運用できるなら、Jotaiは優れたスケーラビリティを発揮します。Suspenseなど最新のReact機能との親和性も高く、それが中心のアプリでは楽しく使えます。
AI支援開発の観点では、予測可能性の面からわずかにZustandに軍配が上がります。新しい状態やアクションを追加する際、「ストアに定義する」という明確な場所があるため、AIアシスタントも迷わずコードを生成しやすいからです。ただし、Jotaiでもきちんと構造化されたコードベースなら十分に保守可能です。Jotaiを選ぶなら、アトムの定義・グループ化ルール(いわばスライスやモジュールに相当)を明文化し、開発者もAIも素早く該当箇所を見つけられるようにしておくとよいでしょう。
最終まとめ
2025年のReactにおける状態管理は、もはや「ひとつの万能解」ではなく、用途に合わせて最適なツールを選ぶ時代です。ZustandとJotaiはその象徴と言えるでしょう。前者はシンプルなグローバルストアの美しさを復権させ、後者は状態を小さなアトムとして再構築することで究極の柔軟性を実現します。どちらも旧来のReduxに比べて冗長さを大幅に改善しながら、高いパフォーマンス、スケーラビリティ、開発者体験を両立しています。
ほとんどのReact開発者にとって、Zustandの構造化されたアプローチがより安全なデフォルトとなるでしょう。特に大規模アプリやチーム開発では、そのコミュニティの勢いが実用性を裏付けています。一方で、Jotaiは細粒度な管理とフック感覚を重視する人にとって新鮮な選択肢です。小規模プロジェクトや、アプリの特定部分だけにまとう複雑さを軽減したい場合には、Jotaiの導入が効果的でしょう。
結局のところ、どちらを選んでも大きく外すことはありません。多くのチームがZustandをアプリ全体の状態管理に、Jotaiを局所的なコンポーネントステートにといったハイブリッド戦略を採っています。この組み合わせは両方の長所を活かしつつ、開発者もAIコーディングアシスタントも扱いやすい、堅牢かつ保守性の高い状態管理戦略を実現します。