「Redux使ってるけど、もっと軽いの無いのかな...」
「Recoilって今でも現役なの?」
「ZustandとJotai、どっちがいいんだろう...」
Reactの状態管理ライブラリ、みなさんも選択に悩んだことありませんか?確かに2025年の今、選択肢の多さに頭を抱えてしまいますよね。Redux、Zustand、Jotai、Recoil、Valtio、XState、TanStack Query...それぞれに「これがウリ!」というポイントがあって、どれを選べばいいのか正直迷っちゃいます。
特にReact 18の登場で状況が更に複雑になってきました。並行レンダリングがどうとか、Server Componentsがこうとか...正直ついていくのが大変です😅
そこで今回は、ChatGPT Deep Researchの力を借りて、各ライブラリの深掘り調査をしてみました。公式ドキュメントはもちろん、GitHub上の議論、実際の使用例、パフォーマンス比較など、かなり広範囲に調べてもらいました。その結果をここで共有したいと思います!
この記事では:
- 基本的な考え方と設計思想
- パフォーマンスや開発のしやすさ
- Server Componentsとの相性
- データ取得との組み合わせ方
- 実際の使用例
- 他のライブラリからの移行方法
- ベストプラクティス
などなど、実践的な観点から比較していきます。「うちのプロジェクトにはどれが合ってるんだろう?」という疑問の答えが、きっと見つかるはずです!
1. 基本概念と設計思想
Reactアプリの状態管理ライブラリとして代表的な Redux Toolkit , Zustand , Jotai , Recoil , Valtio , XState , TanStack Query について、まず各ライブラリの基本概念と設計思想を整理します。
Redux Toolkit
設計思想: Redux Toolkit(RTK)は従来のReduxの公式拡張ツールセットで、Reduxの「 Fluxパターン 」に基づく 一元的なグローバル状態管理 を効率化するために設計されています。アクションやリデューサーの定型コードを削減し、内部でImmutable状態管理のためのImmerなどを利用することで、 簡潔な記述 と 安全な不変更新 を両立します。
インストールと設定:
npm install @reduxjs/toolkit react-redux
RTKにはconfigureStore
が用意され、Redux DevToolsとの連携やミドルウェア設定が簡単です。ストアを作成したらReactアプリ全体を<Provider store={store}>
でラップします。
状態とアクションの定義: Redux ToolkitではcreateSlice
を使って状態スライスとそれに対応するリデューサー・アクションを定義します。例えばカウンターのsliceを定義するコードは以下の通りです。
import { configureStore, createSlice } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
incremented: state => { state.value += 1 }, // Immerにより直接変更記述OK
decremented: state => { state.value -= 1 },
incrementByAmount: (state, action) => { state.value += action.payload }
}
})
export const { incremented, decremented, incrementByAmount } = counterSlice.actions;
export const store = configureStore({
reducer: { counter: counterSlice.reducer }
});
上記のようにcreateSlice
は状態initialState
とreducers
を受け取り、自動的にimmutableな更新処理やaction creatorを生成します。
コンポーネントでの利用方法: Reactコンポーネント内ではuseSelector
フックでストアから必要な状態を取得し、useDispatch
でアクションを発行します。例えば、上記カウンターsliceを利用するコンポーネントは以下のようになります。
import { useSelector, useDispatch } from 'react-redux';
import { incremented, decremented } from './counterSlice';
function CounterComponent() {
const count = useSelector(state => state.counter.value);
const dispatch = useDispatch();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(incremented())}>+1</button>
<button onClick={() => dispatch(decremented())}>-1</button>
</div>
);
}
Redux Toolkitを使うことで、Reduxのボイラープレートコードが大幅に削減され、可読性と保守性が向上します。
Zustand
設計思想: Zustandはドイツ語で「状態」を意味し、その名の通り シンプルかつ最小限 を志向した状態管理ライブラリです。Reactのコンテキストを使わず Reactの外部にグローバル状態を保持 し、 フックAPI 経由でコンポーネントから状態を読み書きします。Reduxにインスパイアされつつも ボイラープレートを排除 し、Fluxパターンを簡略化したデザインです。状態変更にはActionタイプやリデューサーは不要で、直接更新関数を呼び出す形を取ります。
インストールと設定:
npm install zustand
プロバイダでラップする必要はなく、 どこからでも状態フックを呼び出せる のが特徴です。
ストア(状態)の作成: Zustandではcreate()
関数でカスタムフックとして使えるストアを定義します。例えばカウンターのストアは以下のように定義します。
import create from 'zustand';
type CounterState = {
count: number;
increment: () => void;
setCount: (value: number) => void;
};
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
setCount: (value) => set({ count: value })
}));
create
に渡す関数で、set
(状態更新関数)やget
を利用して初期値や更新ロジックを定義します。上記ではincrement
が内部でset
を呼び状態を更新しています。状態はシンプルにJavaScriptオブジェクトで保持され、Immerなしで 直接新しいオブジェクトを返す不変更新 を行います。
コンポーネントでの利用方法: 定義したカスタムフック(上記ではuseCounterStore
)をコンポーネント内で呼び出し、必要な値や関数を分割代入で取得します。
function CounterComponent() {
const { count, increment } = useCounterStore();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
</div>
);
}
これでincrement
を押すとグローバルなcount
が更新され、useCounterStore
を使っている全コンポーネントが必要に応じて再レンダーされます。Zustandは 状態をReactの外に持つ ため、Reactコンテキストによる不要な再レンダーを回避でき、高速に動作します。加えて、状態を選択的に購読するセレクターパターンやシャロー比較もサポートしており、UIの再描画を最小限に抑えられます。ZustandのAPIは極めて小さく(ミニファイ後約1.2KB)、 学習コストが低い 点も特徴です。
Jotai
設計思想: Jotai(ジョタイ、「状態」の意)はFacebook製のRecoilにインスパイアされた 原子的(atomic) な状態管理ライブラリです。状態を Atom (原子)という 最小単位の値 に分割し、Reactコンポーネントは必要なAtomだけをuseAtomフックで読み書きします。設計上、 useStateフックの感覚 で使えるシンプルさと、Atom同士を組み合わせて派生状態を作る柔軟性の両立を目指しています。React 18の並行レンダリング(Concurrency)への対応も意識されており、Reactコンテキストと違って細粒度な再レンダー制御が可能です。
インストールと設定:
npm install jotai
Jotai自体はグローバルなAtomストアを内部的に持っており、特別なプロバイダでラップしなくても利用可能です(必要に応じてJotaiProvider
で独立したスコープを作成可能)。TypeScriptで型安全に使えるよう設計されています。
Atomの作成: Atomはatom(initialValue)
関数で作ります。例えばカウンター用Atomは以下です。
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
また他のAtomから値を導出する 派生Atom も定義できます。例えば倍の値を常に計算するAtom:
const doubledCountAtom = atom((get) => get(countAtom) * 2);
とすることで、countAtom
が変わるとdoubledCountAtom
も自動的に更新されます。
コンポーネントでの利用方法: Atomを利用するコンポーネントではuseAtom
フックを呼び出し、状態と更新関数のタプルを受け取ります。
function CounterComponent() {
const [count, setCount] = useAtom(countAtom); // Atomから状態とSetterを取得
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
useAtom
で得られるAPIはReactのuseState
とほぼ同じ使い心地で、 直感的にグローバル状態を扱える 点が魅力です。JotaiはReactコンポーネントツリー内で状態を管理しますが、 不要な再レンダーは最小限 になるよう設計されています。例えば複数のAtomを使っていても、それぞれのAtomを利用しているコンポーネントだけが更新時に再描画されます。またJotai自体のコアは非常に小さく(約3.5KB gzip)、Recoilより90%小さいとの報告もあります。ただし、コアが小さい分、高度な機能(グローバルな状態の永続化やデバッグツール等)はユーティリティライブラリ(jotai/utils
やコミュニティ製ツール)で補完する形になります。
Recoil
設計思想: RecoilはFacebookが開発した状態管理ライブラリで、Reactのための 分散型グローバル状態 を提供します。Jotaiと同様 Atom と Selector の概念を持ち、Atomに対する細かな依存関係グラフを構築して、影響のあるコンポーネントだけを効率よく再レンダーする思想です。特にReact 18以降の Concurrent Mode (並行レンダリング)でうまく動作するよう設計されており、Suspenseと組み合わせた非同期状態管理など Reactと「Reactらしく」統合 できる点が特徴です。
インストールと設定:
npm install recoil
アプリのエントリで<RecoilRoot>
で囲むことでRecoilのグローバル状態管理が有効になります(Recoilでは全AtomはRecoilRoot配下で動作します)。
AtomとSelectorの作成: Atomはatom({ key: string, default: value })
で作成し、ユニークなキーと初期値を指定します。Selectorはselector({ key, get: ({get}) => ... })
で他のAtomやSelectorの値から派生値を計算します。例えばカウンターAtomとそれを参照するSelector:
import { atom, selector } from 'recoil';
const countState = atom<number>({
key: 'countState',
default: 0
});
const doubledCountState = selector<number>({
key: 'doubledCountState',
get: ({ get }) => get(countState) * 2
});
コンポーネントでの利用方法: コンポーネントではRecoilフックを使ってAtom/Selectorにアクセスします。useRecoilState(atom)
で状態とsetter、useRecoilValue(selector)
で値のみ取得、useSetRecoilState(atom)
でsetterのみ取得することもできます。例:
import { useRecoilState } from 'recoil';
function CounterComponent() {
const [count, setCount] = useRecoilState(countState);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
見ての通り、Recoilの使用感はuseState
に近く 「React的」 です (How to Manage State in a React App – With Hooks, Redux, and More)。Selectorを使うことで 純粋関数による派生データ や 非同期データ取得 も管理でき、ReactのSuspenseと組み合わせて データ取得の待ち を表現することも容易です。Recoilは 柔軟で強力 ですが、その分ライブラリ自体のサイズが大きめ(v0.7.7で約23.5KB gz)で、実験的な位置付けであることに留意が必要です。なお2025年現在、Recoilの公式リポジトリはFacebookによりアーカイブされており、活発な開発は停滞しています。
Valtio
設計思想: Valtioはポーランド語で「状態」を意味し、 Proxy(プロキシ) を用いた独自アプローチの状態管理ライブラリです。オブジェクトをProxyでラップし、その 可変オブジェクト への操作を捕捉して自動的にReactに通知します。開発者は直接オブジェクトのプロパティを読み書きするだけで状態が管理されるため、 シンプルなMutableな体験 と 自動的なリアクティブ更新 を両立します。裏側では変更履歴から 不変なスナップショット を生成し、Reactコンポーネントには安定した値を提供します。
インストール:
npm install valtio
状態の作成: Valtioではproxy()
関数で状態オブジェクトを作成します。例えばカウンター状態:
import { proxy } from 'valtio';
const state = proxy({
count: 0
});
このstate
オブジェクトはProxyでラップされており、直接state.count
を操作することで値を変更できます。
コンポーネントでの利用方法: ReactからはuseSnapshot()
フックでProxyのスナップショットを取得し、そのプロパティを参照します。例えば:
import { useSnapshot } from 'valtio';
function CounterComponent() {
const snap = useSnapshot(state);
return (
<div>
<p>Count: {snap.count}</p>
<button onClick={() => state.count++}>+1</button>
</div>
);
}
このコードでは、ボタンのonClickで直接state.count
をインクリメントしています。ValtioはProxy経由でこの変更を検知し、snap.count
を利用しているコンポーネントを再レンダーします。 どのプロパティが読まれたかを追跡 しているため、不要な部分は再描画されない最適化も行われます(MobXに近い挙動です)。Valtio自体のサイズも小さく(約2.7KB gz)、使い勝手の割に性能面のオーバーヘッドは小さいです。Mutableな操作感を維持しつつ 不変スナップショット を提供する点で、Reactの将来のコンパイラ最適化時代にも対応できるユニークな立ち位置と言えます。
XState
設計思想: XStateはReactに限らずJavaScript全般で使える 状態機械(State Machine)/ステートチャート ライブラリです。アプリの状態を「 有限状態 」と「 イベント 」の組合せで厳密に定義し、許可された遷移以外は起きないようにモデル化します。これにより、複雑な状態遷移や副作用を持つアプリでも 予測可能でバグの少ない 状態管理を実現します。Reactで使う場合は専用のフックuseMachine
を利用し、状態機械の現在のステートと送信関数(send)を得てUIを制御します。
インストール:
npm install xstate @xstate/react
状態機械の定義: 例えばトグルボタンのオン/オフを管理する簡単な状態機械を定義します。
import { createMachine } from 'xstate';
const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: { on: { TOGGLE: 'active' } }, // TOGGLEイベントでactiveへ
active: { on: { TOGGLE: 'inactive' } } // TOGGLEイベントでinactiveへ
}
});
createMachine
で状態(states)と遷移(onでイベントから次状態へのマップ)を定義します。今回は2状態間のトグルですが、実際には階層状態や並行状態、ガード条件付き遷移等、複雑なシナリオを表現できます。
コンポーネントでの利用方法: 定義した状態機械はuseMachine
フックで利用します。
import { useMachine } from '@xstate/react';
function ToggleButton() {
const [state, send] = useMachine(toggleMachine);
return (
<button onClick={() => send('TOGGLE')}>
{state.matches('active') ? 'Active' : 'Inactive'}
</button>
);
}
上記ではstate
が現在の状態を表し、send
でイベント(文字列'TOGGLE'
)を送っています。state.matches('active')
で現在の状態がactive
かを判定し、それによってボタンのラベルを切り替えています。XStateではこのように 状態遷移ロジックをコンポーネントから分離 できるため、テストしやすく再利用可能なロジックの構築が可能です。また状態機械に 副作用(サービス呼び出しやタイマー等) を組み込むこともでき、ReduxのmiddlewareやSagaで行っていた処理も状態機械内で完結できます。もっとも、XState自体のサイズはやや大きめ(約13.4KB gz)で、シンプルな状態管理に適用すると逆に複雑になる場合もあります。そのため 明確に状態遷移が複雑な場合に採用する のが望ましく、小規模なケースでは他の軽量ライブラリやReactのローカル状態で十分なことが多いです。
TanStack Query (React Query)
設計思想: TanStack Query(旧称React Query)は サーバー状態 の取得・キャッシュ・更新に特化したライブラリです。UIにおけるサーバーデータ取得の煩雑さ(ローディング状態やエラー処理、キャッシュ、有効期限管理など)を包括的に解決し、「 データ取得のためのReduxの代替 」として台頭しました。純粋なクライアントグローバル状態ではなく、サーバーから取得するデータ(APIレスポンス等)を扱うための 非同期状態管理 ライブラリと言えます。
インストールと設定:
npm install @tanstack/react-query
利用する際はアプリを<QueryClientProvider>
で囲み、共有のQueryClientインスタンスを提供します。
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
root.render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
クエリの利用方法: 各コンポーネントでuseQuery
フックを使い、データ取得クエリを宣言します。例えばREST APIからtodos一覧を取得する場合:
import { useQuery } from '@tanstack/react-query';
function TodoList() {
const { data, isLoading, error } = useQuery(['todos'], fetchTodoList);
if (isLoading) return <span>Loading...</span>;
if (error) return <span>Error!</span>;
return (
<ul>
{data.map(todo => <li key={todo.id}>{todo.title}</li>)}
</ul>
);
}
第一引数のキー(['todos']
)でクエリ結果がキャッシュされ、再利用時には自動でキャッシュ参照・再フェッチ制御が行われます (Query — Jotai, primitive and flexible state management for React)。TanStack Queryは内部でデータの 変更検知による再レンダー や 背景での再取得、データの正規化 など高度な機能を提供し、開発者は宣言的に「このコンポーネントはこのキーのデータが必要」ということを記述するだけで済みます (Query — Jotai, primitive and flexible state management for React)。
React Query自体は機能が豊富な分サイズはやや大きめですが(v4で約11.4KB gz)、 得られる開発効率の向上 から中~大規模アプリで広く採用されています。
2. 技術評価(パフォーマンス・開発体験・メンテナンス性)
各ライブラリの技術的な評価ポイントとして、パフォーマンス、開発者体験、メンテナンス性を比較します。
パフォーマンス(バンドルサイズ・実行性能・メモリ使用量)
バンドルサイズ
ライブラリの圧縮後サイズを見ると、ZustandやJotai、Valtioは非常に小さく、Redux Toolkit・Recoil・XState・React Queryはやや大きめです。具体的には、Zustandは約 0.6KB (gzip)、Jotaiは 3.5KB 、Valtioは 2.7KB と極小です。これらはReactアプリにほぼ影響を与えないサイズです。一方、Redux Toolkitは ~12.7KB 程度、Recoilは 23.5KB 、XStateは 13.4KB 、TanStack Queryは 11.4-15KB 前後(バージョンによる)となっており、機能性とトレードオフになっています。特にRecoilは機能豊富な分サイズが大きく、Next.js等で使用すると初期ロードに影響が出るとの指摘もあります。
ライブラリ | サイズ (gzip) | 備考 |
---|---|---|
Zustand | 0.6KB | Reactアプリにほぼ影響なし |
Jotai | 3.5KB | Reactアプリにほぼ影響なし |
Valtio | 2.7KB | Reactアプリにほぼ影響なし |
Redux Toolkit | ~12.7KB | 機能性とトレードオフ |
Recoil | 23.5KB | 機能豊富だが初期ロードに影響の可能性あり |
XState | 13.4KB | 機能性とトレードオフ |
TanStack Query | 11.4-15KB | バージョンによって変動 |
実行時性能
再レンダーの最適化 という観点では、各ライブラリで工夫がされています。Zustandはselect関数とシャロー比較で必要な部分のみレンダーする仕組みがあり、不要な再描画を最小化 します。JotaiやRecoilはAtom単位でコンポーネントが購読するため、変更されたAtomに関連するコンポーネントだけが更新されます。ValtioはProxyでプロパティアクセスを追跡 し、触れた値だけを更新するので効率的です。
Reduxはデフォルトでは全てのconnectコンポーネントが毎回計算をしますが、useSelector
フックでslice単位の購読+浅比較を行うため、状態分割と組み合わせれば問題ありません。RTKのImmer利用によるimmutabilityも大きなパフォーマンス問題なく動作します(Immer自体8KB程度でパフォーマンスも許容範囲)。XStateは状態遷移ごとに全ての関連コンポーネントが更新されますが、明確な状態管理ゆえにボトルネックは把握しやすいです。
TanStack Queryはデフォルトでキャッシュと差分更新 が効くため、例えば同じデータを複数コンポーネントで使っても一度のfetchで済み、高頻度更新時もバッチ処理やバックグラウンド更新で効率よく動きます。総じてどのライブラリも実用上十分高速 であり、ボトルネックになることは少ないです。差が出るとすれば、大量の状態を保持する際のメモリ使用量やGarbage Collection頻度ですが、小規模~中規模アプリでは体感差はほぼないでしょう。
メモリ使用量
グローバルに保持する状態量が同じであれば、どのライブラリでも大差ありません。
ただ、RecoilはAtom間の依存グラフ情報を持ち、XStateはステートマシン定義に従った構造を持つため、純粋な値のみを保持するZustandやJotaiに比べて 若干メタデータが多い 傾向があります。またReduxは単一大きなJSオブジェクトを状態として持つので、履歴管理(time-travelなど)をするとコピーコストが増えがちです。
一方、ValtioはMutableなオブジェクトを逐次Proxyで監視してスナップショットを作る実装上、頻繁な大規模変更がある場合にスナップショット生成コストがかさむ可能性があります。しかし通常のフォーム入力やUI状態程度では問題になることはありません。
開発体験(TypeScript対応・デバッグ機能・テスト容易性)
TypeScript対応
現代のライブラリらしく、今回の全てのツールはTypeScriptでの利用が考慮されています。
Redux Toolkitは元々TypeScript利用を推奨しており、configureStore
やcreateSlice
が型推論を効かせてくれます。Zustandもジェネリクスを活用してuseStore
の状態やアクションに型付けが可能です(大規模になると複雑な型も発生しますが基本は良好)。
Jotaiは TypeScriptに非常になじみやすく設計 されており、Atom生成時に型が決まりuseAtom
が適切な型を返します。RecoilもAtomとSelectorの型パラメータを指定でき、最新の型システムに対応しています。
Valtioはプロキシに普通のJSオブジェクトを使うため、型定義もそのオブジェクト型で行えます(プロキシ自体の型は隠蔽される)。XStateは一見複雑ですが、 ステートマシンのコンテキストやイベントにジェネリクス で型を付けられ、高い型安全性を得られます。
TanStack QueryもuseQuery
の戻り値型をジェネリクスで指定するか、fetch関数の戻り値から自動推論してくれるため、TypeScriptとの相性は良いです。
デバッグ機能
Reduxは専用の Redux DevTools エクステンションが事実上標準で、時間遡行や状態差分の確認が容易です。Redux Toolkitでも同様にDevTools連携可能で、actionログを追うことができます。
Zustandは公式にはRedux DevToolsと連携するミドルウェアが提供されており、Zustandの状態変更をRedux DevToolsで観察できます。
Jotaiもシンプルゆえデバッグしやすく、コミュニティ製の開発者ツール(Jotai Devtools)が存在してAtomの状態を一覧・変更できるようになっています。
Recoilは公式に Snapshot 機能(useRecoilSnapshot
やuseGotoRecoilSnapshot
)があり、現在の全Atom状態を取得したり過去の状態に戻したりできます。また有志によるChrome拡張「Recoilize」なども登場し、RecoilRoot内の状態を視覚化・タイムトラベルできるようになっています。
Valtioは比較的新しく軽量なため専用DevToolは少ないですが、Proxyの内容を都度console.log
したりSnapshotを出力することで把握は難しくありません(Mutableに直接変更している分、「いつどこで変わったか」をトレースしたい場合はプロキシのsubscribe
APIなどを利用することになります)。
XStateはStately社から 状態機械の可視化ツール (XState Inspector)が提供されており、ブラウザで現在のステートやイベント、遷移をグラフで確認できます。これにより複雑な状態遷移も一目で把握でき、設計段階からデバッグまで一貫して有用です。
TanStack Queryは公式の React Query Devtools (別パッケージ)があり、現在の全クエリ一覧やキャッシュデータ、fetch状況をリアルタイムで見られるため、データ取得の挙動確認に役立ちます。
テスト容易性
状態管理が 副作用から切り離されている ほどテストは簡単になります。その点、Redux Toolkitは純粋関数であるリデューサーやsliceを単体テストしやすく、Redux storeもテスト用に作ってdispatch→state検証ができます。
Zustandは状態管理ロジックがシンプルな関数(set/get)なので、例えばJestでuseCounterStore.getState()
やuseCounterStore.setState()
を直接呼んで期待通りか確かめることができます。
Jotaiもまた各Atomは純粋な値ホルダーで、副作用がある場合は別途処理します。Reactコンポーネントを関与させずにJotaiの挙動をテストするには、Provider
を用いてテスト用スコープを作り、useAtom
ではなくatom.get()
/atom.set()
を使う低レベルAPIを利用する方法もありますが、基本的にはReactコンポーネントとして統合テストすることが多いでしょう。
Recoilは<RecoilRoot>
をテスト内で使い、その中でコンポーネントをレンダーしてアサートします。RecoilのSnapshot機能で特定時点の全状態を取得して期待値と比較することもできます。
Valtioはグローバルなオブジェクトstate
をそのままテストコードから読み書きできるので、UIを介さずロジックを検証できます。
XStateは定義したマシン(createMachineで作ったオブジェクト)に対して、インタプリタを動かしてイベントを送り、state.value
やstate.context
をチェックする形でテストできます。 状態遷移図そのものがテストケース になるため、仕様通りのイベント系列で期待状態に到達するかを自動テストするのに向いています。
TanStack Queryは、モックサーバーやモックfetch関数と組み合わせてuseQuery
のフックをテストできます。React Testing Libraryでコンポーネントをレンダーし、適切にQueryClientをwrapした上で、await findByText
等でロード後のUIを検証する手法が一般的です。
総じて、どのライブラリもテストは可能ですが、 ビジネスロジックがどれだけUIから分離されているか がテストの容易さに直結します。ReduxやXStateのようにロジックを分離しやすいツールは単体テストがしやすく、ZustandやJotaiもシンプルさゆえにテストしやすいです。React QueryやRecoilのように内部で非同期処理やフレームワークとの連携が深いものは、やや統合テスト寄りになります。
メンテナンス性(開発状況・コミュニティ活発さ・ドキュメント充実度)
開発コミット頻度
Redux ToolkitはRedux公式チームにより積極的にメンテナンスされています。バグ修正や機能追加(例えばRTK Queryの改良)が継続的に行われ、最新のReactやTypeScriptにもすぐ追随しています。
ZustandとJotai、Valtioはいずれもpmndrs(ポモドール)というオープンソースグループで管理されており、主要開発者の一人はDaishi Kato氏です。コミュニティ主導ですが比較的頻繁にコミットやバージョンアップがあり、Issue対応も活発です。
RecoilはFacebook内部プロジェクトとして始まりましたが、前述の通り2025年に公式リポジトリがアーカイブされており、積極的な開発は停止しています。これはReact自体に将来的な改善(Reactコンパイラやサーバーコンポーネント)が入り、Recoilの目的を薄めた可能性があります。
XStateはStately社(XState開発者が設立)が商用サービスを提供しつつOSSとして開発を続けており、v5リリースなど大きなアップデートも定期的に行われています。
TanStack QueryもTanner Linsley氏とコミュニティによって活発に開発され、2023年にv4からv5へのメジャーアップデートがありました(React 18の新機能対応や更なる最適化)。
バグ対応速度
Redux Toolkitは企業利用も多いため、重大なバグには迅速にパッチが提供されます。
Zustand/JotaiもGitHub上で問題提起すれば開発者が比較的早く反応する印象です。Recoilは実験的段階ではIssueが多く寄せられましたが、現状では新規対応は期待しにくいです。
XStateは複雑な分Issueもありますが、コア開発者がTwitterやDiscordで直接サポートすることもあり、対応は丁寧です。
TanStack Queryも使用者が多く、ディスカッションも盛んで、不具合の報告に対する反応は早めです。
総じて、 コミュニティに支えられているライブラリ(ZustandやTanStack Queryなど)の方がIssue対応もオープン であり、 企業内プロジェクト発祥のもの(Recoil)の停滞 が目立ちます。
コミュニティの活発さ
Reduxは長年の支持があり学習資源やミドルウェアエコシステムが豊富ですが、最新では「脱Redux」の流れもありコミュニティは安定期です。
ZustandはReactコミュニティで非常に人気が上がっており、GitHubスター数も増加し多くのブログや記事で取り上げられています。「なぜReduxよりZustandを選ぶか」といったディスカッションもあり、実際にZustandを称賛する声も多いです。
JotaiはRecoilユーザや新しい状態管理を求める層に注目されていて、「Recoil vs Jotai」の比較記事や議論も増えています。
ValtioはZustand/Jotaiほど露出は多くありませんが、「Proxyによる状態管理」というユニークさから興味を持つ開発者もいます。
XStateはReact以外にも使われることもあり、Statechartsという理論バックグラウンドも相まって熱心な支持者がおり、専用のDiscordで知見交換がされています。
TanStack Queryは今やデータ取得の定番となっており、Stack OverflowやブログでのQ&Aも豊富で、企業での採用事例報告も多いです。
ドキュメントの充実度
Redux Toolkitは公式サイトにチュートリアルやレシピが揃っており、困った時はまず公式ドキュメントで解決できるでしょう。
Zustandも公式ドキュメントサイトがありますが、シンプルな分基本的な使い方以上のパターン(複数ストアの組み合わせ等)はコミュニティの情報に頼る部分があります。
Jotaiは公式サイトに豊富なドキュメントと実例があり、React Contextとの違いやユースケース別の解説など親切です。また開発者のブログでRSC対応や他ライブラリ連携など最新情報も発信されています。
Recoilも公式サイトに概念やAPIリファレンスがまとまっており、「データフローグラフ」や「非同期クエリ」の説明など学びやすいドキュメントでした。しかしアーカイブ後の更新は無いので、React 18以降の新機能との組み合わせなどは追記されていません。
Valtioはシンプルゆえドキュメント量は少なめですが、公式リポジトリREADMEとDocサイトで基本と高度な使い方(e.g. Yjsを用いたCRDT同期など)も触れられています。
XStateはドキュメントが非常に充実しており、公式サイトで基本から上級まで体系立てて学べます。可視化ツールやレシピ集もあり、学習コストは高いものの資料が豊富です。
TanStack Queryも公式サイトにガイド・API・高度なトピックが整備され、作者のブログや講演資料も参考になります。
まとめると、 どのライブラリも公式ドキュメント+コミュニティ情報が利用可能 ですが、特にRedux ToolkitとTanStack Query、XStateあたりは公式資料が詳細で、ZustandやValtioはコミュニティナレッジが助けになる場面があるでしょう。
3. React Server Components (RSC) との統合
React 18で導入された React Server Components (RSC) は、Reactコンポーネントをサーバー側でレンダリングし、その結果をクライアントにストリーム送信できる仕組みです。RSCでは サーバー上では状態を持たない(副作用フックも使えない) ため、これらの状態管理ライブラリとの統合には注意が必要です。ここでは各ライブラリがRSCとどう適合するかを評価します。
Redux Toolkit
Reduxは基本的にクライアント側で動作させる想定です。RSCではuseSelector
やuseDispatch
といったフックは使用できません(クライアント専用)ので、サーバーコンポーネント内でReduxの状態を直接参照・更新することはできません。
Next.js 13のApp Router環境でReduxを使う場合、'use client'
を付けたコンポーネントツリー内でProviderを使用する必要があります。もっとも典型的なのは、 サーバー側でデータフェッチ→その結果を初期状態としてクライアントに渡し、Reduxストアを初期化する 方法です。
Redux ToolkitではconfigureStore
時に初期状態を渡せるため、RSCから取得したデータをページprops経由で注入し、クライアント側で<Provider>
内のストアに設定するといった実装になります。要するに、 Redux自体はRSC上では動かさず、RSCはデータ取得のみ担当しクライアントコンポーネントにデータを渡す 役割を担います。
Reduxのグローバル状態はクライアントで管理・永続化し、RSCとは明確に分離するのが無難です(RSC上でReduxストアを再構築すると状態が毎リクエスト初期化されるため)。
Zustand
Zustandの状態も 基本的にはクライアント側で保持 します。ZustandはReactの外に状態を置くため一見RSCでも使えそうに見えますが、RSCでカスタムフックuseStore
を呼ぶことはできません(RSCはHooks禁止)。そのため、サーバーコンポーネント内で直接Zustandの状態にアクセスすることは避けます。
Next.jsなどでSSRを行う場合、Zustandの状態を hydrate(クライアント再水和) する手順が必要です。一般的なパターンは、サーバーでデータを取得してそれをJSONとして埋め込み、クライアントに渡してからZustandのsetState
で初期化する方法です。Zustand自体は公式SSRサポートは限定的ですが、コミュニティではzustand/middleware
にpersist機能があり、createJSONStorage
等を使ってサーバーからの初期値をローカルストレージ経由で渡す例もあります。
Next.js App Routerでは、サーバーコンポーネントからクライアントコンポーネントにプロップで値を渡せるので、その値を受け取ったクライアントコンポーネント内でZustandの初期化を行うという形になります。 要点 : Zustand自体はRSC内で状態を保持できませんが、 RSCから値を受け取ってクライアント側Zustandストアに設定 することで統合できます。
Jotai
JotaiはRSCとの統合について比較的明確なサポートを提供しています。まずAtom自体はクライアント側で評価されますが、サーバーで取得した値をクライアントのAtomに水和(hydrate)するためのユーティリティuseHydrateAtoms
があります (SSR — Jotai, primitive and flexible state management for React)。
Next.js 13+では、サーバーコンポーネントでデータをフェッチし、それを子のクライアントコンポーネントにプロップで渡し、useHydrateAtoms([[atom, value], ...])
を呼ぶことで、クライアント側でそのAtomに初期値をセットできます (SSR — Jotai, primitive and flexible state management for React) (SSR — Jotai, primitive and flexible state management for React)。以下にその実装例を示します。
// サーバーコンポーネント(例)※実際には 'use client' なし
export default async function Page() {
const count = await fetchCountFromDB(); // サーバーでデータ取得
return <CounterPage countFromServer={count} />;
}
// クライアントコンポーネント(例)
'use client';
import { useAtom } from 'jotai';
import { useHydrateAtoms } from 'jotai/utils';
import { countAtom } from './state'; // atom(0)で定義済み
function CounterPage({ countFromServer }) {
useHydrateAtoms([[countAtom, countFromServer]]); // Atomに初期値を注入 ([SSR — Jotai, primitive and flexible state management for React](https://jotai.org/docs/utilities/ssr#:~:text=const%20CounterPage%20%3D%20%28,))
const [count, setCount] = useAtom(countAtom);
// これ以降は通常のJotai利用と同じ
...
}
上記のように、JotaiではuseHydrateAtoms
を使うことで サーバーで準備した状態をクライアント側Atomに反映 できます (SSR — Jotai, primitive and flexible state management for React)。このフック自体はクライアントコンポーネント内で呼ぶ必要があります('use client'
必須) (SSR — Jotai, primitive and flexible state management for React)。
JotaiはReact Context類似の仕組みでAtomを保持しますが、React 18の並行レンダリング下でも動作するよう調整されています。
RSCと組み合わせる際の注意点として、Atomを複数回hydrateしない(同じAtomに対しuseHydrateAtoms
を複数回呼ばない)ようにすることや、サーバー→クライアント間でAtomオブジェクトそのものを共有し(プロップで渡し)てしまうと参照違いで別Atom扱いになるため Atom定義は共有モジュールに書く ことなどが挙げられます。
Recoil
Recoilも基本的にはクライアント側状態ですが、React 18対応でSSRサポートが検討されてきました。Recoil公式には「サーバー側ではRecoil状態をセットできない」と明言されています。
Next.js 13+のApp RouterでRecoilを使う場合、<RecoilRoot>
は通常クライアントコンポーネント内に置きます。サーバーコンポーネントから受け取ったデータを初期値にAtomを初期化する仕組みはJotaiほど用意されていません。考えられる方法としては、RecoilのinitializeState
(RecoilRootにプロップとして渡す初期化関数)を使い、サーバーから渡されたデータで特定のAtomを初期化するというアプローチがあります。
ただNext.js App RouterではRecoilRoot自体がクライアントでレンダリングされるため、その場でしか初期化できません。またRecoilはReact状態と同様に、サーバーとクライアントで同じAtomを共有してしまうと不整合が生じるため、結局のところ RecoilはSSRよりCSRで使うもの と割り切った方がシンプルです。
RSCとは直接統合せず、RSCはデータ取得後プレーンなデータを渡し、クライアント側で受け取ってRecoilのuseSetRecoilState
等でセットするイメージです。なおRecoilには派生ライブラリ「Recoil Sync」があり、URLやストレージ、他のストアとAtomを同期する仕組みを提供していますが、それでも サーバーコンポーネント上で動作させるのは非推奨 です。
Valtio
ValtioもZustand同様にグローバル(モジュールスコープ)なオブジェクトに状態を保持します。したがってサーバーコンポーネントで直接そのProxyにアクセスすると、サーバーで状態が生まれてしまいクライアントとは別物になります。RSCはリクエストごとに新しい環境になるため、Valtioのグローバル状態は毎回初期化され、継続性がありません。
これを避けるには、やはりサーバーでは状態を持たずデータだけ渡し、クライアントでValtioのproxy()
を使って状態を構築する必要があります。もしくは、Valtioのvaltio/vanilla
にSSR用ユーティリティ(getProxySnapshot
など)があるので、サーバーでスナップショットを作ってシリアライズし、クライアントでproxy()
に流し込むという手もあります。
いずれにせよ、ValtioもRSC上でProxyを永続化することはできないため、初期データの受け渡しとクライアントでの再構成が必要です。
XState
XStateのステートマシンも基本的にはクライアント側で利用します。もっとも、XStateはUIロジックだけでなくビジネスロジックを状態機械として表現するため、 サーバーサイドで機械を動かしその結果だけHTMLに反映 するといった使い方も理論上は可能です。
しかし実際には、複数クライアントにまたがる長時間の状態をサーバーで持つことはセッション管理などが必要になり複雑です。そのためRSCとの関係では、XStateも クライアントコンポーネント内でuseMachine
を使う ことになります。
サーバーで予め決定できるUI状態であれば、ステートマシンを使わずともサーバーコンポーネント内の条件分岐で済むでしょう。一方、チャットの状態やフォームウィザードの進行状況など、ユーザー操作で変わる一連の状態をサーバーで管理するのは不自然です。
要するに、XStateは 従来通りクライアント側の状態管理 として割り切り、RSCとは特に直接の統合を行わないケースが多いです。もしRSCから初期ステートやコンテキストデータを与えたい場合は、他のライブラリと同様、プロップ経由でマシンの初期コンテキストにその値を埋め込む形で渡すことになります。
TanStack Query
TanStack QueryはNext.jsなどで SSR(サーバープリレンダリング) する際の仕組みが公式に用意されています。dehydrate
とHydrate
を用いることで、サーバーで実行したクエリ結果を一時的にJSONとして出力し、クライアントでそのJSONをもとにキャッシュを再構成するアプローチです。
Next.js 13のApp Router + RSC環境でも、サーバーコンポーネントでデータ取得を行い、<Suspense>
で区切ってクライアント側でuseQuery
を同じキーで呼ぶことで、自動的にデータが再利用されるパターンがあります。具体的には、サーバーコンポーネントでawait fetch...
した結果を子に渡し、子(クライアントコンポーネント)でuseQuery(key, fetchFn, { initialData: fetchedData })
とする方法です。React Queryは内部的に キーに対するデータの存在をチェック し、initialDataがあればフェッチをスキップします。
あるいは、App Routerでは使いづらいですが、旧Pages Routerのように自前でdehydrate(queryClient)
した結果を<script>
タグで埋め込み、Hydrate
コンポーネントでラップする方法もあります。TanStack Query v5では、React 18の新しい機構への対応も進み、fetchOnMount
やrefetchOnReconnect
等の既定動作もRSC時には調整可能です。
まとめると : TanStack Queryは サーバーでデータを取得しクライアントにシームレスに引き継ぐ能力 が高く、RSC環境でも問題なく利用できます。他の状態管理と違い、むしろ「サーバーからデータを持ってくる役割」そのものなので、RSCとは 役割分担が明確 です。RSC上で状態を持たないという点も、React Queryには追い風で、サーバーコンポーネントでは単にデータfetchを行い結果をHTMLに埋め込むのみに留め、クライアントでリアクティブにキャッシュ更新・再取得を行うという使い分けができます。
RSC統合の実装例: Jotaiを用いたSSR/CSR間の状態受け渡し
上記Jotaiの項で示したように、JotaiではuseHydrateAtoms
でAtomに初期値を注入できます (SSR — Jotai, primitive and flexible state management for React)。Next.js 13のAppディレクトリを想定した例を再掲します。
// atoms.ts (共有モジュール)
export const countAtom = atom(0);
// page.tsx (サーバーコンポーネント)
export default async function Page() {
const count = await fetchCountFromDB();
return <CounterPage countFromServer={count} />;
}
// CounterPage.tsx (クライアントコンポーネント)
'use client';
import { useAtom } from 'jotai';
import { useHydrateAtoms } from 'jotai/utils';
import { countAtom } from './atoms';
export default function CounterPage({ countFromServer }) {
// サーバーから受け取った値でAtomを初期化
useHydrateAtoms([[countAtom, countFromServer]]); ([SSR — Jotai, primitive and flexible state management for React](https://jotai.org/docs/utilities/ssr#:~:text=const%20CounterPage%20%3D%20%28,))
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>Count (from server): {count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
このようにしておくと、サーバー上で取得したcount
値が初回レンダリング時から反映され、その後クライアント上で状態として利用・更新することができます (SSR — Jotai, primitive and flexible state management for React)。RSCではサーバーとクライアントの役割分担を意識し、 「サーバーはソースデータ取得、クライアントはインタラクションと状態管理」 とするのがポイントです。各状態管理ライブラリもその原則に沿って統合することが望ましく、無理にRSCで状態を持とうとしないことが現状のベストプラクティスです(ReactチームもRSCで状態を共有する手段は公式に提供していません)。
制約事項や考慮点
RSCとクライアント状態を組み合わせる際は、以下の点に注意が必要です。
- サーバーコンポーネントからクライアントコンポーネントへはシリアライズ可能なデータしか渡せません。したがって、 関数やクラスインスタンス、シンボルなどを含む状態は渡せない ので注意(XStateのマシンインスタンスやReduxストアそのものは渡せないということです)。
-
'use client'
ディレクティブを付けたモジュール内ではRSCのメリット(遅延読み込みや自動分割)が減るため、できるだけクライアント部分(状態管理部分)は必要な最小限の範囲に留めること。 - サーバーとクライアントで重複してデータ取得しないように設計する。React Queryの
initialData
のように 二重フェッチ防止 の仕組みを活用するか、自前でフラグ管理をする必要があります。 - 状態の永続化(localStorage等)や、ユーザー入力中の一時データについては、サーバーに送る必要のないものもあります。そうした 純粋クライアント状態はRSCでは扱わず に、クライアントコンポーネント内のみで完結させるのも手です。
- ライブラリによってはRSC非対応のAPIを含む可能性があります(たとえば、一部のデバッグ用フックが
useLayoutEffect
を使用しているなど)。その場合はそのAPI呼び出しをクライアント側に限定してください。
現時点(2025年)では、Reactのコンパイラ(React Compiler)や将来の新機能によって、グローバル状態管理の役割が変わる可能性がありますが、RSC時代においても クライアント状態管理は依然重要 です。各ライブラリとも完全なRSC統合ではなく、 「サーバーはデータ取得、クライアントは状態管理」という責務分離 のもとで共存させるのが現実解となります。
4. データフェッチング統合
状態管理ライブラリを語る上で、 サーバーデータ(REST APIやGraphQL、tRPCなど)との統合 は欠かせません。伝統的にReduxではredux-thunkやredux-sagaを用いてAPIコールと状態保存を行ってきましたが、近年は サーバー状態は専用ライブラリ(React QueryやSWR等)に任せ、クライアントグローバル状態と分離 するアプローチが主流です。ここでは各ライブラリとデータフェッチとの統合のしやすさ、具体例について解説します。
Redux Toolkit + RTK Query
Redux Toolkitは公式に RTK Query というデータ取得キャッシュライブラリを内包しています。RTK Queryを使うと、Reduxストア内に 標準化されたAPIキャッシュスライス を構築でき、createApi
でエンドポイント定義を行い、自動的にAPI通信、キャッシュ、loading状態管理などを行ってくれます。React Queryとの違いは、 Reduxの仕組みに統合 されている点です。
すなわちfetchリクエストもReduxのactionとしてdispatchされ、DevToolsで逐次確認でき、他のsliceのリデューサーでAPIの成功/失敗アクションを拾って別のグローバル状態を更新する、といった連携も可能です。例えば、あるエンドポイントが成功したら別のキャッシュを無効化する、といった高度な挙動もRedux内で完結できます。
RTK Queryの導入は簡単で、apiSlice = createApi({ baseQuery, endpoints })
を定義してstoreにmiddlewareとして追加し、コンポーネントではuseGetXQuery
のような自動生成フックを使います。Redux利用プロジェクトでは 追加依存を増やさず にデータ取得機能を持てるため、RTK Queryは有力です。
一方で、Redux非利用の環境では使えない点と、キャッシュがReduxストアに載るぶんストアサイズが肥大化する可能性があります。GraphQLの場合も基本的にはクエリ関数をfetchベースで書けば動きますが、Apollo Clientほど手厚いGraphQL専用機能(例えば正規化キャッシュ)はありません。それでもReduxユーザにとっては 最も自然にREST/GraphQL通信を組み込める手段 でしょう。RTK Queryは 自動再フェッチやキャッシュポリシー も設定可能で、Redux Toolkitユーザならまず検討すべき統合策です。
Zustand + (React Query/SWRなど)
Zustand自体はデータ取得の仕組みを持ちません。しかしZustandの強みは 他のライブラリと疎結合 なことです。
例えば、サーバー状態管理には TanStack Query(React Query) を使い、ローカルのUI状態や一部のグローバルUI設定にZustandを使うという構成がよく取られます。React QueryはZustandと同じReact外部の概念(QueryClient)でデータを持つので、両者が競合することはありません。実践的には、 「グローバルなサーバーデータ=React Query、グローバルなUI状態やビジネスロジック状態=Zustand」 と役割分担します。
ZustandとReact Queryを直接連携させる必要はあまりなく、それぞれをコンポーネント内で必要に応じて呼べばOKです。どうしてもReact Queryで取得したデータをZustandの他の状態と組み合わせたい場合、React QueryのonSuccess
コールバックでZustandのsetState
を呼ぶ、くらいのシンプルな統合で足ります。
一方、Zustand側に REST API呼び出しを実装する ことも可能です。例えばZustandのアクション内でfetch
して結果をset
で保存すれば、そのデータがグローバル状態として扱えます。ただしエラーやローディング管理、キャッシュの有効期限などを自前でやるのは大変なので、React Query等との組み合わせが推奨されます。
また SWR (Vercel製のReact Hooksライブラリ)とも相性が良いです。SWRはReact Query類似の機能を持つので、Zustandはローカル状態、SWRはサーバー状態というようにシンプルに使い分けられます。実際、あるプロジェクトではReduxを廃止して SWR + Zustand に移行した例もあります。
Zustandは 好きなデータ取得手段を併用できる柔軟性 が魅力であり、TanStack Queryとの組合せはコミュニティでも「最新のベストプラクティス」として受け入れられています。
Jotai + Apollo Client / React Query 等
JotaiはAtomを通じて他のツールと連携しやすいよう、公式にいくつかの拡張パッケージを提供しています。例えば jotai-tanstack-query パッケージでは、React Queryのクエリ結果をAtomとして扱えるatomWithQuery
が提供されています。これを使うと、従来useQuery
で取得していたデータをAtom経由で管理でき、Atomを読むコンポーネントは自動的にSuspenseでローディング待ちするなど、JotaiとReact Queryのシームレスな統合が可能です (Query — Jotai, primitive and flexible state management for React) (Query — Jotai, primitive and flexible state management for React)。実装例:
import { atomWithQuery } from 'jotai-tanstack-query';
import { queryClient } from '../queryClient'; // 作成済みのQueryClientを利用
const todosAtom = atomWithQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodoList
}));
そしてコンポーネント側でuseAtom(todosAtom)
とすれば、内部で自動的にfetchTodoList
が実行されdata
やisError
などの状態も取得できます (Query — Jotai, primitive and flexible state management for React)。Jotaiには他にも jotai-apollo というApollo Client統合パッケージがあります。GraphQLクエリを記述し、atomWithQuery
(Apollo用にカスタムされたもの)でAtom化することで、Apollo ClientのキャッシュとJotaiのAtomを連動させられます (Apollo Graphql Client Integration For Jotai - daily.dev)。例えば:
import { atomWithQuery } from 'jotai-apollo';
import { client } from '../apolloClient';
import { gql } from '@apollo/client';
const GET_TODOS = gql`{ todos { id title } }`;
const todosAtom = atomWithQuery(client, { query: GET_TODOS });
こうするとuseAtom(todosAtom)
でApolloのuseQuery
と同様のデータ取得が行われます。裏側ではApollo Clientのインスタンスclient
に対してclient.query
を実行し、結果をAtomに格納しています。Jotaiはこのように Atomを介して他ライブラリの状態と自然に橋渡し できる設計で、公式からReact QueryやURQL(GraphQLクライアント), Apollo, Relayなどとの統合が提供されています。
これにより、 サーバー由来の状態もAtomとして扱い、他のAtomと組み合わせた派生状態を作る ことができます。例えば、REST APIの結果(AtomA)とユーザー入力のAtomBから新たなAtomCを導出し、その結果を表示する、といった高度な組み合わせも容易です。
加えて、Jotaiは非同期Atom(読み出し関数がPromiseを返すAtom)もサポートしており、簡易的にconst dataAtom = atom(async get => { const res = await fetch(...); return res.json(); })
と書けばコンポーネント側でSuspenseによりデータ取得を待つこともできます (apollo async atom · pmndrs jotai · Discussion #1069 - GitHub)。このように、Jotaiは ローカル・リモート問わず全ての状態をAtomにマッピング可能 で、必要に応じて適材適所のデータ取得ライブラリと併用できます。
Recoil + GraphQL/REST
Recoilはデータ取得のために Selectorの非同期対応 という仕組みを持っています。Selectorのget
関数内でawait
やPromiseを返すことが可能で、ReactのSuspenseを用いることでコンポーネントのレンダリングをデータ取得完了まで止めることができます。例えば:
const userIdState = atom({ key:'userId', default: 1 });
const userDataState = selector({
key: 'userData',
get: async ({ get }) => {
const id = get(userIdState);
const response = await fetch(`/api/users/${id}`);
if(!response.ok) throw new Error('Failed to fetch');
return response.json();
}
});
これでuseRecoilValue(userDataState)
を呼ぶコンポーネントは、自動的にデータ取得完了までSuspense fallbackを表示します。Recoilはこのように 独自にデータフェッチ機能を内包 しており、追加ライブラリ無しで簡易的なデータ取得はできます。
しかし高度なキャッシュポリシーや再試行制御などは自前実装になるため、複雑な場合はReact QueryやApollo Clientと組み合わせるケースもあります。Recoilと他ライブラリの直接的な統合はありませんが、例えばApollo Clientで取得したデータをRecoilのAtomにセットし、他のコンポーネントでそのAtomを使う、といったことは可能です。
Recoil公式の追加ライブラリ「Recoil Sync」はGraphQLとの統合も念頭に置かれていますが、こちらは主にAtom状態と外部ソース(URLやストレージ)の同期が目的です。総じて、Recoilは 軽度のデータ取得なら組み込み機能で対処できる ものの、 本格的なサーバー状態管理には専用ツールを併用 した方がよいでしょう。
実際、Apollo ClientやReact Queryを使い、それらがもつキャッシュをRecoilで再ラップせずにそのまま活用する方が効率的な場合が多いです。
Valtio + データ取得
ValtioもZustand同様データ取得機構はありません。Proxyでラップしたstateに対して自由に変更できるため、例えば以下のように書けます。
const state = proxy({ todos: [], loading: false });
const fetchTodos = async () => {
state.loading = true;
const res = await fetch('/api/todos');
state.todos = await res.json();
state.loading = false;
};
Reactコンポーネントはsnap = useSnapshot(state)
でsnap.todos
やsnap.loading
を参照し、変化に応じて再描画されます。これは非常にシンプルですが、エラー処理やキャッシュをするなら自分でコードを追加する必要があります。Valtioには非公式にSWRとの統合Hook(useProxyとSWRを組み合わせたもの)もコミュニティで紹介されています。
とはいえ、Valtioは 一部の状態を他と同期しながら変えたいとき に有用で、例えばWebSocketから受け取ったリアルタイムデータをValtio stateにどんどんミューテートしていけば、それを参照するUIが自動更新されるといった使い方ができます。RESTのリクエスト/レスポンス型のデータにはReact Query、リアルタイム双方向データにはValtio、といった 使い分け も考えられます。
XState + データ取得
XStateのステートマシン内では、状態遷移の一部として サービス(サイドエフェクト) を呼び出すことができます。つまり、ある状態に遷移するときにAPIリクエストを送り、そのPromiseがresolve/rejectしたら別の状態に遷移する、といった ワークフロー全体を機械で表現 できます。XStateではinvoke
という構文でPromiseを呼び出し、成功時・失敗時の遷移先状態(およびコンテキストへのデータ格納)を宣言します。例えば:
const machine = createMachine({
id: 'fetchExample',
initial: 'idle',
context: { data: null, error: null },
states: {
idle: { on: { FETCH: 'loading' } },
loading: {
invoke: {
src: (context, event) => fetch('/api/data').then(res => res.json()),
onDone: { target: 'success', actions: assign({ data: (_, event) => event.data }) },
onError: { target: 'failure', actions: assign({ error: (_, event) => event.data }) }
}
},
success: { /* ... */ },
failure: { /* ... */ }
}
});
これで、send('FETCH')
すると勝手にloading
状態に遷移しfetch実行、成功すればsuccess
状態になりcontext.dataに結果が入る、失敗ならfailure
状態でcontext.errorにエラーが入る、といった一連の流れを一箇所で定義できます。これはredux-saga等で書いていた「ローディングフラグ管理」「成功/失敗時の状態更新」「リトライ遷移」などを網羅できます。
XStateはREST/GraphQL問わず シーケンシャルな処理フロー を明示的に記述できるのが強みです。特に複数のAPI呼び出しを順次行う場合や、ユーザー操作とAPI結果が組み合わさって状態が遷移するケースでは、状態機械で記述するとわかりやすくなります。
ただし、単一のデータ取得だけであれば、React Query等に比べてやや大げさです。XStateは ビジネスロジックレイヤー としてAPIコールも内包する、という位置づけで、他の状態管理とは異なる観点と言えます。GraphQLとの直接連携機能はありませんが、invokeでApollo Clientを呼ぶこともできますし、GraphQLサブスクリプションをXStateの並行状態(parallel states)で待ち受けるなど高度なパターンも可能です。
TanStack Query (React Query)
既に詳述したとおり、TanStack Query自体がサーバー状態管理専業ツールなので、他のライブラリとの組み合わせの中心となります。React QueryはRESTもGraphQLも対応可能で、fetcher関数次第です。GraphQLならApollo Clientほどの機能はないですが、単純にfetch(graphqlEndpoint, { query })
のようにfetcherを書けば動きます。作者によれば、Apollo Clientの重厚さに対しReact Queryはシンプルさ重視という住み分けがあります。
tRPC(タイプセーフなRPCフレームワーク)は裏でReact Queryを利用しており、@trpc/react-queryというラッパーを通じてReact Queryのcacheキーやupdateロジックを自動生成します。このように、React Queryは 様々なバックエンドとの接続に使われるデファクト標準 になりつつあります。
React Query単体でも十分ですが、前述の通りJotaiやRedux Toolkit等と組み合わせて使われることも多いです。
実装例: JotaiのatomWithQueryでReact Queryを統合
Jotai + TanStack Queryの統合コードを一例示します (Query — Jotai, primitive and flexible state management for React) (Query — Jotai, primitive and flexible state management for React)。
import { atomWithQuery } from 'jotai-tanstack-query';
import { queryClient } from '../queryClient'; // 共有のQueryClient
// React QueryのuseQuery相当の設定をatomWithQueryで記述
const todosAtom = atomWithQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodoList
// optionsも指定可能(staleTime, cacheTime, enabled など)
}));
function TodoList() {
const [{ data, isLoading, isError }] = useAtom(todosAtom);
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error!</p>;
return (
<ul>
{data.map(item => <li key={item.id}>{item.title}</li>)}
</ul>
);
}
上記ではJotaiのAtomとしてtodosAtom
を定義し、内部でTanStack Queryのキーと取得関数を指定しています。コンポーネント側ではuseAtom(todosAtom)
でdata
等にアクセスでき、React Queryが裏で走っていることを意識せず使えます (Query — Jotai, primitive and flexible state management for React)。またqueryClientAtom
を使えば既存のQueryClientインスタンスをJotai側から参照することもでき、アプリ全体でReact Queryのキャッシュを共有できます (Query — Jotai, primitive and flexible state management for React)。このような統合により、 グローバル状態ライブラリとデータ取得ライブラリの境界が曖昧になるくらい融合 できます。
とはいえ基本方針は先述のとおり、「サーバー状態はReact Query等、アプリ内状態はZustand/Jotai等」で分けておく方がシンプルです。ときにReduxのように一元管理したくなることもありますが、近年のアプローチは 責務分離 によりコードの見通しと性能を両立しています。
5. アーキテクチャと設計の違い
ここでは各ライブラリのアーキテクチャ上の違いを整理します。特に グローバル状態 vs ローカル状態 の扱い、 Atomベースのアプローチ と State Machineアプローチ の違いに注目します。
グローバルステートとローカルステートの扱い
Redux(Toolkit)は 単一のグローバルストア に全アプリの状態を集約する設計です。このため小さな状態も全てグローバルになる傾向がありますが、逆に 一箇所で全体を把握 できる利点があります。
ZustandやValtioも デフォルトではグローバルなストア/オブジェクト をモジュールスコープに定義して使います。しかしReduxと違い複数の独立ストアを作ることも可能で、用途ごとにstoreファイルを分けてインポートしても問題ありません(Zustandのcreate()
を複数回呼ぶ、Valtioのproxy()
を複数作る)。
JotaiとRecoilは グローバルなコンテナ(Jotaiのデフォルトストア or RecoilRoot) の中でAtom単位に状態を管理します。Atomはグローバルにも局所的にもなり得ます。例えばJotaiでは、あるコンポーネントファイル内で定義したAtomをそのコンポーネントツリー内だけで使うこともできますし、別ファイルから同じAtomをインポートすれば共有されます。Recoilも同じRecoilRoot内ならAtomは共有されますが、異なるRecoilRootを設ければ状態が分離されます。つまり Jotai/Recoilは必要に応じてグローバルにも局所にもできる 柔軟さがあります。
一方、XStateは少し観点が異なり、 一つのマシンは特定のロジック領域を担当 します。XStateをグローバル状態として使うというより、UIの特定フロー(ページ遷移やモーダルステップ、フォーム状態など)ごとにマシンを作り、それを必要なコンポーネントツリーで使う形です。複数のマシン間でデータ共有する場合は、親子マシンにしたりサービス経由で連携させたりします。
TanStack QueryはグローバルなQueryClientを共有しつつも、クエリごとのデータはクエリキーで分離されています。したがってReact Queryのデータ自体はグローバルキャッシュ上にありますが、UI側からは ローカルにuseQueryを呼んだコンポーネントのみが関心を持つ という点で、グローバルかつ局所的とも言えます。
まとめると、 Redux/Zustandは基本グローバル、Jotai/Recoilはグローバルな中の局所単位、XStateはロジック単位、React Queryはデータソース単位 と、スコープの切り方が異なります。
Atomベースのアプローチ(Jotai, Recoil)
JotaiとRecoilはいずれも Atom という独立した状態単位を持つアーキテクチャです。これは データフローグラフ を構築できるのが特徴です。
複数のAtomやSelector(派生計算)に分解することで、依存関係を明示して宣言的に状態を表現できます。例えば、フィルター条件Atom, リストデータAtom, フィルター後リストSelector、と分ければ、それぞれの変更に応じて必要な部分だけ再計算・再レンダーされます。これはReduxの単一大オブジェクトを手動で分割管理するのに比べ、 非常に細かく状態を管理 できる利点です。
裏を返せば、Atomが増えすぎると把握が難しくなる恐れがあります。開発者はAtom間の関係を頭の中で整理する必要があり、適切な命名と構造化が求められます。RecoilではAtomにユニークキーを付け、依存グラフはライブラリが管理します。Jotaiでは同じAtomオブジェクトを共有する限り同一の状態ですが、新たにatom()を呼ぶと別物になるため、 参照の共有 が重要です。たとえばコンポーネント内でconst countAtom = atom(0)
と毎回定義すると毎レンダーごとに新しいAtomになり意図した挙動になりません。こうした Atomのライフサイクル管理 は開発者の責任なので、基本はモジュールスコープにAtom定義を置くのが定石です。
Atomアーキテクチャは Reactのレンダリングと親和性 が高く、先述のSuspenseによる非同期処理とも噛み合わせやすいです。また、Recoil/Jotaiはいずれも 「Contextの代替」 を強調しており、React Contextでグローバル状態を渡すと全コンスーマが再レンダーしてしまう問題を解決する目的が大きいです。実際Jotaiの作者も「React Contextの再レンダー問題を避けるためのライブラリ」と述べています。
State Machines(XState)
XStateのアーキテクチャは他と全く異なり、 形式的な状態遷移の定義 に基づきます。状態は列挙された 有限集合 (state nodes)しか取り得ず、イベントによってのみ遷移します。これにより、許可されていない状態の組み合わせが起こらないよう制約できます。
例えば、Reduxで複数のブール値で表していた「ロード中」「成功」「エラー」状態も、XStateなら{ idle, loading, success, failure }
という互いに排他的な状態として管理できます。複雑なアプリでは「状態の組み合わせ爆発」に悩まされますが、State Machineはそれを 一元的な状態遷移図 で管理し、人間が理解しやすい形にします。
XStateはまた 副作用(サイドエフェクト) も状態定義に組み込めるため、ミスが減ります。Reduxではコンポーネントでdispatchを呼ぶ場所や順序に依存してバグが出ることがありますが、XStateではイベントに対する反応(トランジションとアクション)がcentralizedに定義されるので、見通しが良くなります。
デメリットとしては、 実装量が増える ことと 学習コスト です。単純なカウンターをXStateで書くとReduxやZustandより冗長になります。従って、XStateは 本当に状態遷移を厳密に扱いたい部分に限定 して使われることが多いです(例:マルチステップフォーム、支払いフロー、音声通話の接続状態管理など)。
一方、Reactアプリ全体のグローバル状態管理にXStateを使うことも不可能ではありません。全アプリ状態を巨大なステートチャートとして定義すれば理論上管理できますが、現実的ではありません。そこで、局所的なマシンを複数導入し、それらの間で必要に応じてイベント通信(機械同士がメッセージを送り合う仕組みがXStateにはあります)する、といった構成が考えられます。これはあたかも人間のチームに役割ごとのリーダー(マシン)を置き、全体として協調しているようなイメージです。
いずれにせよ、XStateは 「状態の厳密性と可視性」を重視したアーキテクチャ であり、他のライブラリのような「とりあえず値を共有する」用途には向きません。逆に、 重要な状態遷移を失敗なく実装したい 場面ではXStateが活きてきます。
Immutable vs Mutable
ライブラリ間の設計思想の違いとして 状態の不変性 の扱い方も挙げられます。Reduxは「すべての状態は不変オブジェクト、常にコピーして更新」が原則です。Redux ToolkitではImmerにより実際のコード上はミューテートしても裏で不変更新に変換しています。
Zustandは内部的には新しいオブジェクトを返すことを推奨しています(深いネストがなければ浅いコピーで良いのでシンプル)。Valtioだけは 直接ミューテーション を採用しつつ不変スナップショット生成で整合性を保つというハイブリッドです。
JotaiとRecoilは、Atomごとの値がプリミティブならば再代入(セット)で更新しますが、オブジェクトなら新オブジェクトに差し替える方が推奨されます。Atomは単位が小さいため、そこまで厳密に不変性にこだわらなくとも影響範囲は限定されます。例えばconst userAtom = atom({ name: 'Alice', age: 30 })
なら、setUser(u => ({ ...u, age: 31 }))
のように不変更新しますが、設計としてnameAtom
とageAtom
に分けておけばそれぞれプリミティブなので関係ありません。このように、 Atom設計では状態分割が自然に進むため不変性の問題が緩和 されます。
XStateはコンテキストデータを不変に扱うかmutableにするか選べますが、推奨は不変です。なぜなら純粋関数的に状態遷移を定義したほうが予測しやすく、デバッグも容易だからです。
TanStack Queryはキャッシュデータをimmerなど使わずそのまま格納します。ユーザーが取得したデータオブジェクトを直接書き換えるのは基本的に非推奨で、新しいfetch結果で置き換える運用になります。
総じて、 最近の主流は不変性重視 ですが、Valtioのように「JSのオブジェクトは可変」という前提を受け入れて柔軟性を優先するケースもあります。それぞれ一長一短で、不変性を強制すればバグが減る反面コーディング量が増え、可変を許せばコードは直感的になるが思わぬ副作用バグに注意が要るという関係です。
UIレンダリングへの影響
アーキテクチャによる大きな違いは Reactの再レンダリング制御 にも現れます。Reduxのように コンテナコンポーネント+connect のパターン(もしくは最新のuseSelector
)では、状態のどの部分をそのコンポーネントが利用するかを選択することで再レンダーを最適化します。Zustandも同様に、カスタムフックにセレクタを渡して内部で比較することで不要なレンダーを避けます。
JotaiとRecoilは 自動で粒度の細かい購読 を行うため、基本的に最適化のための工夫は少なくて済みます。Valtioは プロキシによる依存追跡 で最小限の再レンダーを保証します(MobXと似ていますがさらに自動化されています)。
XStateは、状態が変わればuseMachine
の戻り値が変わるためコンポーネントは更新されます。複数のコンポーネントで一つのマシンの異なる部分に反応したい場合、機械の状態をグローバルに置いてそれぞれが部分的に購読する仕組みを作らないといけません。これはあまり例がない使い方ですが、React ReduxのconnectのmapStateToPropsを自分で書くようなイメージで、XStateのstate.context
から必要な値を取り出しReactのコンテキストで配信する、といったことも可能でしょう。
TanStack Queryのレンダリング制御はHook内部で完結しており、一度データがfetchされればuseQuery
が使われている全コンポーネントが自動更新されます。しかし更新頻度やタイミング(たとえばデータが更新されたら即他コンポーネントに通知するかどうか)はオプションで調整可能です。React Queryはデフォルトで「マウント時やフォーカス時の再フェッチ」「バックグラウンドでのデータ更新」など仕組みが働くため、 ユーザーが意識しなくてもUIが最新に保たれる という利点があります。これも「グローバルなデータ」だけど「ローカルなHookで扱う」という設計の妙です。
以上、アーキテクチャ面の差異をまとめると、 Redux/Zustandは手続き的・単一データ構造 , Jotai/Recoilは宣言的データフロー・小粒度状態 , Valtioはプロキシによる直感的操作 , XStateは形式的モデル , TanStack Queryは非同期キャッシュ という棲み分けになります。
6. 実践的な導入事例
各ライブラリの実際の導入事例とその分析を紹介します。それぞれが現場でどのように使われ、 成功要因 や 直面した課題 、 学習曲線 、 メンテナンス性 について言及します。
Redux Toolkit の導入事例
Redux(Toolkit含む)は長らく標準的な選択肢であり、多数の企業で採用されてきました。例えば、大規模なSNSクライアントや管理画面など 複雑なUI状態と大量のサーバーデータ を扱う場面で、その一元管理能力が評価されています。
成功要因としては、 チームでのコード規約が統一できる 点が大きいです。Reduxは「状態はこう管理する」という定石が確立しており、新メンバーもReduxを知っていればすぐ戦力になります。Redux DevToolsを使ったデバッグや、Actionログを用いた不具合調査など、エコシステムが充実していることも安心感につながります。
課題としては、 ボイラープレートの多さ と 柔軟性の低さ です。小さな変更でもAction型定義やReducer追記が必要なため、開発がやや冗長になります。ただRedux Toolkit導入によりその点はかなり緩和されました。またReduxは強いパターンがあるがゆえに、パターン外の要件への対応(例えば履歴管理や一部コンポーネントだけで状態を閉じたい場合)が難しいケースもあります。その場合はReduxに固執せずReact Contextや別の状態管理との併用も検討されます。
学習曲線は緩やかで、Fluxパターンさえ理解すればToolkitのAPIは平易です。既存プロジェクトへの導入も容易で、多くの旧ReduxプロジェクトがToolkitへ移行しています。メンテナンス性は非常に高く、Redux公式チームの長期サポートもあり、 技術的負債になりにくい 安全な選択肢と言えます。
Zustand の導入事例
Zustandは最近急速に採用例が増えており、 中小規模のウェブアプリ や スタートアップの新規プロジェクト で好まれる傾向があります。具体的な事例として、ある大規模ECサイトでReduxからZustandに移行したところ、 バンドルサイズが大幅削減 されアプリ全体のパフォーマンス改善に寄与したとの報告があります。
また別の例では、個人開発のダッシュボードアプリでZustandを使ったところ 数分で基本実装ができ、直感的に状態を追加できた といった声があります。成功要因は、やはり APIの簡潔さと柔軟さ です。「3分でセットアップでき、おそらくReactで最も学習が容易なグローバル状態管理ライブラリ」と評されるほどで、Reduxのような厳格なパターンに縛られないため開発者の発想に合わせて使えます。特にプロトタイピングや、小機能を素早く実装したい場面で強みが発揮されます。
課題としては、 大規模化した際の統制 です。自由すぎるがゆえに、チームで多人数が開発すると状態の定義や更新ロジックが散逸しやすいという指摘があります。Reduxならディレクトリ構成やファイル分割で組織化できますが、Zustandではそうした規約を自前で用意しないといけません。また、依存関係がない分何でもできるため、例えばReact以外(純JS)のコードからZustand状態を更新するといったことも可能で、それがバグの原因になったりするとデバッグが難しくなります。そのため、チーム開発では「Zustandでも Redux的な運用ルール (例えば全更新関数はfluxライクに書く等)を設ける」ケースもあります。
学習曲線は非常に低く、新人でもuseStateに似た感覚ですぐ理解できます。ドキュメントも短時間で読めます。メンテナンス性は比較的良好ですが、上述のように独自ルールが緩いと後から別の人がコードを追うのが難しくなる恐れがあります。しかしコミュニティのサポートもあり、問題が起きても調べやすい点で不安は少ないでしょう。
Jotai の導入事例
Jotaiは比較的新しいながら、一部でRecoilからの乗り換え先として採用されています。例えば、あるスタートアップのプロジェクトでRecoilを試験導入したものの、パフォーマンス上の懸念(ライブラリサイズや依存関係の多さ)からJotaiに切り替えたケースがあります。
このプロジェクトでは 既存のRecoil Atom/Selectorの概念がJotaiでもほぼそのまま使え たため、移行はスムーズに進み、アプリの挙動も問題なく維持できたと報告されています(Jotaiは「Recoilに近い設計思想」であるため互換性が高い)。成功要因は、 Recoilの良い部分(Atomモデル)を継承しつつ軽量化・簡素化 した点です。
Jotaiは「Boilerplateゼロ」を掲げており、実際導入してみると 非常に少ないコードでグローバル状態が構築できる ことに驚く開発者も多いです。またTypeScriptサポートが万全なため、大規模な型定義を持つアプリでも型の心配なく使えています。
課題としては、Jotai自体のエコシステムがまだ成長途中であることです。例えばデバッグツールは発展途上であり、Redux DevToolsほどの成熟度はありません。しかしJotaiはReact Developer ToolsでAtomごとの状態を見るプラグインを提供し始めており、改善が進んでいます。
学習曲線は緩やかで、Recoilを知っていれば即理解できますし、そうでなくてもuseState
類似のAPIなので初学者にも優しいです。メンテナンス性については、Jotaiはpmndrsグループがメンテナンスしており安定した更新があります。コミュニティも小さいながら熱心で、GitHub Discussionsで質問すれば開発者本人から回答が得られることもあります。
概ね 「Recoilの実験的要素をそぎ落として実用性に振った」 という評価で、Recoilの導入を躊躇していた層にとって魅力的な選択肢となっています。
Recoil の導入事例
RecoilはFacebook発ということで、Facebook内部(例えばFacebook.comの特定機能やInstagramチームなど)で試験的に使われたと推測されます。ただ外部公開後、大規模に採用した企業の事例はそれほど多く報告されていません。
中規模プロジェクトで導入した例として、UI上で複雑に絡み合うフィルターや検索条件をRecoilのSelectorでelegantlyに実装できたという話があります。成功要因は Reactとの親和性 です。RecoilはReact思考で設計されているため、React好きな開発者には「自然に感じる」ライブラリでした。特に、ある状態を様々な派生状態に変換して使うような場面(データの部分集合や、計算値のキャッシュなど)でSelectorが威力を発揮し、 ソース状態と表示状態の分離 が簡単にできたといいます。
課題は、 実験的であったがゆえの不安定さ でした。Recoilはv0.x台が長く続き、破壊的変更も時折あったため、プロダクションで使うにはリスクがあると見る向きもありました。またSSR対応の遅れや、バンドルサイズの大きさから「アプリ初期ロードに負荷をかけた」という指摘もありました。結果として、Recoilを導入したプロジェクトでも後にJotaiやZustandにリプレースした例が見られます。
学習コストはそれほど高くなく、「Recoilはすぐ習得できたがその先の高度な使い方(atomFamilyや複雑な非同期チェーン)は難しかった」という声も。これはRecoil特有の概念の多さ(Atom, Selector, Family, Snapshotなど)に起因します。一方メンテナンス性は、Facebookが停止した今後は疑問符が付きます。コミュニティがフォークして続ける可能性もありますが、公式の動きが止まったため、新規採用は減少するでしょう。
総括すると、 Recoilはコンセプト実証には成功したが、実運用では他に譲った ケースが多い印象です。
Valtio の導入事例
Valtioはニッチな用途で光るライブラリです。導入事例として、あるゲーム開発のツールでValtioが使われています。そのツールではUI上にオブジェクトのプロパティを編集するフォームがあり、オブジェクトモデル(Plain JS Object)が複雑でした。
Valtioを使うことで、そのオブジェクトをProxy化して フォーム入力によるオブジェクト更新をリアルタイムに別UIに反映 したり、逆に他UIでの変更をフォームに即座に反映したりといったことが、極めて簡単に実装できたそうです。これは Mutableオブジェクトをそのまま共有 できるValtioならではの強みです。他のライブラリなら一度Reduxストアに変換するとか、イベント発行して通知するといった作業が必要ですが、Valtioでは「ただ同じオブジェクトを指しているだけ」で同期が取れるわけです。
成功要因は JavaScriptの標準機能(Proxy)を活用した直感的モデル で、既存コードへの侵入が少ない点です。課題は、大規模データやパフォーマンスクリティカルな場面での使用です。Proxyはブラウザ実装に依存する部分もあり、またデバッグが難しいケースもあります。ValtioのプロキシはReactコンポーネント外でも動くため、不用意に大きなオブジェクトをProxy化して頻繁にミューテートすると、知らぬ間にレンダリングキューが溜まるリスクもあります。
学習コストは低めで、「使ってみると魔法のようだが裏で何が起こっているか理解するのは難しい」という声もあります。つまりブラックボックス感があり、バグ時に「Valtioの中で何が?」となりがちです。メンテナンス性は、pmndrs製品であることからZustand/Jotaiと似ています。コミュニティも小さいですが支持者はいます。ただしValtioでないと絶対にできないことは限られるため、用途が合えば採用、そうでなければZustandなどで良いという位置づけです。
XState の導入事例
XStateは特定の要件にフィットした場合に強力です。有名な例として、あるビデオ通話アプリでXStateが採用されました。ビデオ通話は「未接続」「接続要求中」「接続済み」「切断中」など状態が入り組み、ネットワークエラーやユーザーキャンセルなどイベントも多岐にわたります。
そこにXStateを導入したことで、 全ての可能な状態遷移を可視化 し、想定外の状態(例えば接続要求中に切断処理が来たらどうなる?など)を排除できました。さらに状態遷移のテストも自動化され、バグが激減したとのことです。「デバッグが非常に楽になった。状態マシンを修正すればバグも潰れる」という開発者の声があり、XState採用を高く評価しています。
一方、平凡なCRUD画面にXStateを導入しようとして挫折した例もあります。それは 状態が単純すぎてステートマシンの恩恵が少ない ケースでした。結局Reduxで十分だったり、Reactのローカル状態で事足りる部分にXStateを使うと、かえってコード量が増えてしまいます。
学習曲線は最も急で、Statecharts理論やXState独自の文法を理解する必要があります。これをチーム全員が習熟するには時間がかかるため、導入にはトップダウンの強い意思が必要です。
メンテナンス性については、一度しっかり状態機械を構築すれば 仕様変更に強い というメリットがあります。要件が変わった際にも状態図を書き換えればよく、変更漏れが少なくなります。ただ、その恩恵を受けるのは複雑な要件の場合のみです。XStateコミュニティは熱狂的で、導入事例共有やサポートも活発なため、ハマるととことん使い倒せるでしょう。
TanStack Query (React Query) の導入事例
TanStack Query (React Query) は「Reduxをリプレイスした」例が数多くあります。例えばSaaS系のある企業では、以前はReduxでAPIデータを管理していたのをReact Queryに置き換えたところ、コード量が大幅に減りバグが減少したといいます。
理由は、Reduxで手書きしていたローディング・エラー・キャッシュ処理がReact Queryでは自動化されたからです。これにより開発者はUIロジックに集中できるようになりました。また、React Queryのキャッシュ共有によって、同じデータを必要とするコンポーネント間で不要なリクエストが発生しなくなりパフォーマンスも向上しました。
別の事例では、ClassDojo社が既存のカスタムデータ取得フレームワークからReact Queryへの移行を行い、開発効率と状態整合性が改善したと技術ブログで報告しています。成功要因は開発者体験の良さと即効性です。React Queryは導入するとすぐにローディングスピナーやエラー表示処理が簡素化され、「もっと早く使えば良かった」という声が多いです。
課題としては、抽象度が高いがゆえの理解コストです。魔法のように色々やってくれるため、デフォルト挙動を把握せずに使っていると意図しない再フェッチが起きたりして驚くことがあります(例えばウインドウフォーカス時の再フェッチ機能など)。しかしこれもオプション調整で解決します。
学習曲線は適度にあり、キャッシュキーの設計や、useQuery
/useMutation
のパターンに慣れる必要があります。ただRedux+Thunkで様々な状態を自前管理するよりは遙かに平易です。
メンテナンス性は素晴らしく、公式の更新も頻繁で信頼性があります。React Queryを採用したことによる大きな問題はほぼ報告されておらず、安定してメリットを享受できるライブラリという評判です。2025年現在では多くのReact開発者がまずReact Query/TanStack Queryを検討するほど浸透しています。
以上の事例から、各ライブラリの 成功要因 はそのライブラリの特長と一致し、 課題 は適用範囲を外れた使い方をすると顕在化することが分かります。 学習コストと得られる効果 もトレードオフで、XStateのように高コスト高効果もあれば、Zustandのように低コスト中効果のものもあります。 メンテナンス性 は、コミュニティや開発体制によっても変わりますが、概ね人気ライブラリは長期の安定が期待できます。Recoilのような例外もありますが、これはReact自体の進化と戦略転換による部分が大きいでしょう。
7. 移行ガイドライン
既存プロジェクトで別の状態管理ライブラリへ移行する場合の指針を示します。ここでは 互換性の考慮事項 や 移行時の課題 に触れつつ、具体的な移行手順のヒントを述べます。
ReduxからZustand/Jotaiへの移行
従来Reduxを使っていたプロジェクトで、より軽量なZustandやJotaiに移行するケースが増えています。この場合、移行は段階的に行うのが現実的です。一気に全てを書き換えるのはリスクが高いため、まずReduxのストアと並行してZustand(またはJotai)のストアを導入し、新規実装部分から徐々にZustandを使うようにします。
ReduxのstateをZustandに引き継ぐために、一時的にReduxのsubscribeでZustandのsetを呼ぶブリッジを入れることもできますが、長期間併用するのは複雑になるので、段階移行とはいえ最終的にはReduxを取り除くゴールを定めます。Redux→Zustandは比較的素直で、同じ状態更新ロジックを実装し直すだけで済むことが多いです。Zustandはfluxライクにも書けるので、Reduxのreducer内の処理をそのままZustandのset
内に移植するという方法もあります。
一方、Redux→Jotaiはアプローチが異なるため大きなリファクタになります。Reduxの大きなstateをAtomに分解しなおし、コンポーネントでuseSelectorしていた箇所はuseAtomに置き換える必要があります。Action/Reducerの概念もなくなるので、状態変更箇所を直接Atom setに変えていくことになります。
移行時の課題として、Redux特有の機能の置き換えがあります。例えばRedux Sagaで非同期処理していた場合、Zustandでは単にasync関数内でset
すればよいですが、Sagaのロジックを見直す必要があります。Reduxのミドルウェア(ロギングやDevTools)はZustandにも類似機能がありますが、完全一致ではないので調整が要ります。
移行手順まとめ:
- 新ライブラリ(Zustand/Jotaiなど)をインストールし基本設定。
- 既存Redux状態の中から一部のsliceを選び、新ライブラリで同等の状態と更新関数を定義。
- そのsliceを使っていたコンポーネントを修正(useSelector→useStore or useAtomなど)。
- テスト・動作確認。問題なければRedux側のそのslice関連コードを削除。
- sliceごとに繰り返し、最終的にReduxに依存する部分を0にする。
互換性の考慮として、アプリ全体の挙動が変わらないよう 状態の初期値 や 永続化 に注意します。
ReduxのstoreをLocalStorageに保存していた場合、Zustandでもpersistミドルウェアを使って同じキーで保存すればシームレスに引き継げるかもしれません。また、URLにReduxの状態を反映していた実装(例: クエリパラメータにページネーションやフィルタ条件を保持など)は、新ライブラリでも同様の処理を追加する必要があります(JotaiならatomWithStorage
やatomWithHash
等のユーティリティが利用可、Zustandならsubscribeしてwindow.historyにpushする等)。
RecoilからJotaiへの移行
RecoilユーザーがJotaiへ移る場合、 概念が近い ので比較的楽です。RecoilのAtomはJotaiでもAtom、SelectorはJotaiでは派生Atomで表現できます。大きな違いはRecoilは各Atomにkey
が必要でしたがJotaiは不要という点と、JotaiではファミリーAtom用のユーティリティは別途用意する必要がある点です(例えばRecoilのatomFamily
に相当するものはJotaiではatom((get) => ...)
内で動的に管理するか、自前でMapを使うなどする)。
実移行では、RecoilのuseRecoilState(myAtom)
をuseAtom(myAtom)
に機械的に置換し、Atom/Selector定義部分をJotai形式に書き換えます。Jotaiでは文字列キーがないため、 重複Atom防止のためのキー管理 から解放される利点があります。またRecoilRootは削除し、Jotaiでは基本不要ですが、テストや分離目的でProvider
を使っていたなら<JotaiProvider>
に置換します。
Recoilの高度な機能(例えばトランザクションなど)はJotaiにはありませんので、使っていた場合は代替手段を検討します。Recoilのバッチ更新(setRecoilState
を連続呼んだときまとめてレンダリング)などはReactの自動バッチングに委ねる形で問題ありません。
不具合が起きやすいポイント として、RecoilではAtomの比較は参照ではなくキーで管理されていたため同じキーなら同一扱いでしたが、Jotaiでは 同じatom関数でも毎回呼べば別インスタンス になります。移行時に動的にAtomを生成していた箇所があると注意が必要です。解決策は、Atom定義をモジュールスコープに上げて再利用するか、もしくはJotaiのatomFamily
パターン(自前でMap管理する方法)を実装します。このあたりが移行の課題になります。
概してRecoil→Jotai移行は 1対1に置き換わる部分が多く 、時間も比較的少なく済むでしょう。
Context APIからRedux/他ライブラリへの移行
React Contextでグローバル状態管理していたケースでは、パフォーマンス問題や状態管理が煩雑になって別ライブラリ導入を検討することがあります。この場合、既存のContextプロバイダとコンスーマ(useContext)を新ライブラリに置換します。例えばContextでユーザー情報を配っていたなら、Zustandのstoreでユーザー情報を持ち、useContextしていた箇所をZustandのuseStore
に変えるだけです。再レンダー範囲が小さくなるため基本的にパフォーマンス改善します。互換性としては、ContextはReact Developer Toolsで手軽に値を確認できましたが、Zustand等でもDevTools拡張を用意しておくと良いでしょう。またContextはコンポーネントツリー毎に独立可能でしたが、Zustandなどはデフォルトでアプリ全体共有になるので、 局所状態はどう管理するか を決める必要があります。必要ならZustandもcreateStoreを複数使ってコンテキスト化するか、あるいはその部分はReact Contextのまま残す手もあります。
移行時の互換性チェック
どの移行でも、 テストの整備 が重要です。状態管理の変更はアプリ挙動全般に影響するため、単体テスト・統合テスト・E2Eテストを駆使して、移行による不具合を早期発見できる体制を作ります。また一部ユーザーにのみ新実装を有効化する フィーチャートグル を用い、段階的に移行をリリースすることも考えられます。
Edge casesの対応
Reduxでdeep compareしていたロジックがZustandでは動かない、などエッジケースに注意します。例えば浮動小数点誤差やNaNなど特殊な値の比較、またDateやMapなどの扱いも、ライブラリ間で微妙に異なります。移行後もしばらくはバグ報告に耳を傾け、 違いを吸収する コーディング(必要ならutility関数を噛ませるなど)をします。
ドキュメント・コードコメント
移行作業中は、新旧の対応表や、なぜ移行するのか・どこまで移行済みか、といった情報をドキュメント化してチームと共有します。メンバーが混乱しないよう、例えば「旧Redux storeは現在Read-Onlyで徐々に削除予定、状態更新は新Zustandを使うこと」といったガイドラインを周知します。移行完了後もしばらくは古い知識でコードを読んでしまう人がいるかもしれないので、READMEやアーキテクチャ決定記録(ADR)などに変更内容を書くと親切です。
互換性の考慮事項:
- UI上の挙動(ローディングインジケータやエラーメッセージ表示など)が変わらないよう、新ライブラリでも同等の制御を実装する。Redux→React Queryでは、React Queryの
isLoading
等を使うように変更するが、UIの見た目やタイミングが変わらぬよう注意する。 - 状態の初期化順序。Reduxでは親コンポーネントでProvider包んで一気に初期化していたが、Zustandではインポート順に実行されるため、必要なら明示的な初期化処理を追加する。
- サードパーティとの連携。Redux-FormやRedux PersistなどRedux特有のライブラリを使っていた場合、それらの代替(React Hook Formや直接LocalStorage保存)に移行する。
- 型定義の互換。TypeScriptの場合、ReduxのRootState型を大量に使っていたなら、その参照先を新ライブラリの型に変更する必要があります。Zustandならstore.getState()のReturnType、Jotaiなら各Atomの型を組み合わせて以前の構造を表現できるか検討します。最悪anyで逃げても動きますが、型安全性を落とさないよう工夫します。
漸進的アップグレード vs リファクタ一括
状況によりますが、多くの場合漸進的が現実的です。ただ小規模アプリや初期段階のプロジェクトなら、一括で入れ替えてしまう方が早い場合もあります。リファクタリングツールや正規表現置換で機械的に変換できる部分(例えばdispatch(someAction(payload))
をsomeStore.setSomething(payload)
に置換など)は一括変換し、それ以外は手動で直す、といったハイブリッドも可能です。
移行はプロジェクトにとって大きなイベントですが、適切なガイドラインと段取りで進めれば 安全に技術スタックをモダナイズ できます。例えばpmndrsコミュニティはReduxからZustandへの移行支援ツール「ReduxToZustand」を提供しており、既存のReduxストア定義から自動でZustandコードを生成する試みもあります。このようなリソースも活用すると良いでしょう。
8. ベストプラクティス
最後に、各ライブラリ共通・個別のベストプラクティスをまとめます。 よくある問題とその解決策 、 推奨される設計パターン を列挙します。
グローバル状態は必要最小限に
どのライブラリを使う場合でも、「とりあえず全部グローバルに乗せる」は避け、 本当に共有すべき状態 だけをグローバルにします。例えば、一つのページ内だけで完結するUI状態(モーダルの開閉など)は極力そのコンポーネントのローカルstateで管理し、グローバルストアに入れない方が見通しが良くなります。グローバル状態数が増えると管理コストも上がるため、 React Contextやpropsで十分な場合はそれで済ませる ことも考慮しましょう。
Zustandの再レンダー最適化
Zustandでは、 状態をオブジェクトでまとめ過ぎない ことがベストプラクティスです。例えばuseStore
でオブジェクトごと取得すると、そのオブジェクトの任意のプロパティが変わるだけでコンポーネントが再レンダーされます。代わりに、useStore(state => state.someProperty)
のように 個別の値を選択 して取得することで、不要なレンダーを防げます。更に、複数の値が必要ならuseStore(state => ({val1: state.a, val2: state.b}), shallow)
とシャロー比較を使うと効果的です。Zustand公式ではこれを推奨しており、特に大きなオブジェクト(例えば配列何百件など)を状態に持つときは、必要な要素だけ取り出すselectorを活用してください。
Jotai/RecoilのAtom設計
Atomは シンプルな値を持たせ、複雑な計算はSelector/派生Atomで行う のが原則です。こうすることでデバッグ時に各Atomの値を容易に観察でき、分離もしやすくなります。
また Atomの数を恐れない ことも重要です。1画面に数十個のAtomがあっても性能上問題になることはほぼありません(Atom変更時に関係ないコンポーネントは再レンダーしないため)。逆に、一つのAtomに巨大なオブジェクト(例えばフォーム全体の状態)を詰め込むと、一箇所変わるだけでそのAtom使っている全要素が更新されてしまいます。
RecoilではAtomFamily機能を使ってKeyパラメータごとにAtomを作ることで粒度を調整できますし、JotaiでもatomFamily
相当のパターンが提案されています。
状態の正規化 (Normalization)も有効です。ReduxではよくNormalized StateでIDで参照する形にしますが、同様にRecoil/Jotaiでも、配列をそのままAtomにするのではなくオブジェクトをMapに持ちidsAtom
とentitiesAtom
に分けて管理するなどの手法が取れます。これは特に大量のデータを扱う場合にレンダリング負荷を下げるのに役立ちます。
エラーとローディングの扱い
グローバル状態管理には データ取得の状態(loading, error)をどこで持つか という問題が付きまといます。
Reduxの場合、典型的にはstateにisLoading
やerrorMessage
を持ちますが、これを乱立させると状態ツリーが煩雑になります。React QueryやRecoilのSuspenseを使うとこれらを自動化できますが、無造作にSuspenseを使うとどこでローディングが発生するか追いづらくなる懸念もあります。
ベストプラクティスは、 エラーとローディングはなるべくUI近辺で管理する ことです。例えばReduxを使っていても、各コンポーネントでisLoading
をローカルstateで持ち、dispatch前後でトグルする、といったことも状況によってはアリです。React Queryの場合はuseQuery
が返すisError
等をそのままUIに利用すればよく、グローバルstoreにコピーする必要はありません。
要は、 グローバルに持つべきは「複数箇所で共有する必要があるエラー/ローディング状態」に限る ということです。
デバッグモードの活用
ReduxではDevTools、RecoilではSnapshotデバッグ、Zustand/Jotaiでも各種Devtoolがあるので、 開発中は積極的に利用 しましょう。特にRedux DevToolsは、状態履歴を遡ってバグ再現したり、過去の状態をエクスポートしてチームに共有するなど強力な機能があります。Jotaiでも開発者がデバッグ用のAtomを追加しuseAtomDevtools
(非公式)でブラウザ拡張に繋ぐテクニックがあります。これにより、 複雑な状態の変遷を可視化 して問題解決を早められます。
状態オブジェクトの変更はコピーしてから
不変パターンを取らないValtio以外では、原則 状態は直接ミューテートしない ようにしましょう。Redux ToolkitではImmerがあるので一見直接代入でもOKですが、それはImmerが処理しているだけで実際には新オブジェクトを作っています。Zustandでもset(state => { state.x = 1 })
のように書くと、Immerのような処理はなく 実際にミューテート してしまうのでバグのもとです。正しくはset(state => ({ ...state, x: 1 }))
とすべきです。Jotai/RecoilのsetterもsetCount(count + 1)
のように直接渡すと問題ありませんが、オブジェクトの場合setObj(obj => ({ ...obj, foo: 'bar' }))
としてください。これらは基本ですが、つい楽をしてミューテートしてしまうミスが起きがちなので注意です。
依存関係の循環に注意
Atom同士、Selector同士で 循環参照 しないよう設計します。RecoilではSelector A -> B -> Aのようになると実行時エラーになります。JotaiでもAtomのget内で循環が起これば永久に計算が終わりません。状態設計時にグラフが巡回しないよう、 明確な層を分ける ことが重要です。例えばUI入力AtomとデータAtomは分離し、どちらか一方だけがもう一方を見るようにする(双方向にしない)。もしくは共通の下位Atomに両方が依存する形にするなど工夫します。これはReduxでも同様で、状態正規化しておかないと一部を更新したとき他との矛盾が生じる可能性があります。
XStateのベストプラクティス
XStateを使うときは、 できるだけ状態図を事前に描く ことが推奨されます。文字コード上でcreateMachineを書く前に、ペンと紙やMermaid記法等で状態遷移図を書くと、必要な状態・イベントが整理され、実装がスムーズです。また大きなマシンはサブステートに分割し、parallel states
(並行状態)などXStateの機能を活用して複雑性を抑えます。XStateではcontext
がReduxのstate相当ですが、コンテキストに何でも入れすぎないこともポイントです。できるだけ 状態はstate nodeで表現し、contextは補助的な値の保持だけ に使うと、状態遷移が明確になります。副作用(APIコールなど)はservices
として定義し、可能ならテストではモックに差し替えて機械本体は純粋に保つ方が望ましいです。これにより状態機械をユニットテストでフルカバレッジにすることも可能です。
React Queryのベストプラクティス
TanStack Queryを使う際は、 クエリキーを一意に設計 することが大切です。キーが衝突すると異なるデータが混同される恐れがあります。逆にキーの粒度が細かすぎるとキャッシュが効率的に使えません。IDやフィルター値を組み合わせてキー配列を作る際は、その構造をチームで統一しましょう。
もう一つ、 楽だからとグローバル状態にデータをコピーしない ことです。React QueryのデータをReduxやZustandに写すとソースが二重化し、同期が煩雑になります。基本はReact Queryのキャッシュを単一の情報源として信用し、必要ならReact QueryのSelector機能(第3引数でselect)で加工して使います。どうしてもグローバルに保持したいなら、JotaiのatomWithQueryのように ソースオブジェクトは一つにして参照を共有 する形にしましょう。
分かりやすいコード構造
状態管理ライブラリを導入すると、それ専用のファイルやフォルダ構造が必要になります。Reduxならstore/
ディレクトリ、Zustandならstores/
フォルダで各storeファイル、Jotaiならatoms/
ディレクトリなど、プロジェクトに応じて 場所を整理 しましょう。また命名も統一します。例えばZustandならuseXYZStore
というカスタムフック名、JotaiならsomethingAtom
、XStateならXMachine
など、一目で種類と内容が分かるようにします。こうすることで、新しい開発者も迷わず変更箇所を見つけられます。ドメインごとに状態管理ファイルを分けるのも有効です(例: user関連はuserStore.ts、product関連はproductAtoms.tsなど)。
不要になった状態の整理
時とともに不要になったグローバル状態は 適宜削除 しましょう。状態管理は便利ですが、使われなくなったグローバル状態が残っているとメンテナンス性を下げます。Reduxなら使われていないaction/reducerを消す、Zustandなら不要なstoreを畳む、Jotai/Recoilなら使っていないAtomを片付ける、といったリファクタを定期的に実施します。ツールによっては未使用Atomの警告などは出ませんので、コードベースをgrepするなどして確認する必要があります。
パフォーマンスチューニング
いずれのライブラリでも、 大きなリストの描画や高頻度更新 などでは工夫が必要です。例えば1000アイテムのリストをすべてグローバル状態で持ち、それぞれが個別に更新通知を受けるとレンダリング負荷が高くなります。そういう場合はPaginationやWindowing(表示部分だけレンダリング)を導入し、状態管理ライブラリ側だけでなくUI側でも負荷軽減を図ります。また、ReactのuseTransition
やuseDeferredValue
を活用して、重い状態更新によるブロッキングを防ぐテクニックもReact 18以降は有効です。状態管理ライブラリだけに頼らず、 Reactの提供する性能改善API もうまく組み合わせてください。
総じて、状態管理のベストプラクティスは 「シンプルに、局所に、しかし整理はしっかり」 に尽きます。必要以上にグローバルにせず、しかし必要なグローバル状態は一貫して扱う。ライブラリ固有の機能は便利ですが、乱用せずコードの可読性とバランスを取りながら活用するのが理想です。上述のポイントを意識することで、Reactにおける状態管理をより堅牢かつ効率的に運用できるでしょう。