新規プロジェクトでReduxを使うのをやめた理由
約8ヶ月前、Redux Toolkitで動いている中規模のReactダッシュボードを引き継ぎました。動作は問題なく、状態は予測可能で、DevToolsも優秀、チームもパターンを把握していました。しかし新機能が必要になるたびに、モーダルの開閉状態を管理するだけの簡単なものでも、スライスを書いて、アクションを追加して、セレクターを配線して、最低4つのファイルを触ることになっていました。グローバルなローディングフラグ1つのために。
この手間が代替手段を探すきっかけになりました。サイドプロジェクトでJotai、Recoil、Zustandをテストした後、社内ツールの1つをZustandに移行しました。6ヶ月後、状態に関連したインシデントはゼロのまま本番で稼働しています。そこで学んだことをまとめます。
ReduxとZustand:アプローチの比較
本質的には、どちらのライブラリも同じ問題を解決しています——プロップドリルなしにコンポーネント間で状態を共有すること。違いは、そこに到達するまでに必要な「儀式」の量です。
Redux(Redux Toolkit使用)
Reduxは厳格な単方向データフローを強制します。リデューサーとアクションを持つスライスを定義し、コンポーネントからそれらのアクションをディスパッチし、セレクターで状態を読み取ります。Redux Toolkitは素のReduxに比べてボイラープレートを大幅に削減しますが、アクション、リデューサー、ストア、ディスパッチサイクルを理解するというメンタルモデルは変わりません。
複雑な非同期フローを持つ大規模チームでは、その構造が報われます。予測可能性とDevToolsサポートは他に追いつくものがありません。しかし小規模なチームや、オーバーヘッドが正当化されない機能には——食料品の買い物にフルプレートアーマーを着るようなものです。
Zustand
Zustandはそのモデルを完全に捨てています。ストアをプレーンなJavaScriptオブジェクトとして作成し、状態とアクションが一緒に存在します。コンポーネントはフックでサブスクライブし、使用している特定の状態が変わったときだけ再レンダリングされます。
Providerも不要。dispatchも不要。アクションタイプも不要。関数を呼ぶだけです。useStateとuseContextで状態を管理する感覚に近く、大きなコンポーネントツリー全体でのコンテキスト再レンダリングによるパフォーマンス上の落とし穴もありません。
実際の本番使用後のメリットとデメリット
Zustandのメリット
- ボイラープレートが最小限:非同期アクションを含む完全なストアが30行以内に収まります。Redux Toolkitでは、スライスファイル、非同期サンク、セレクター定義が複数の場所に分散します。
- Providerが不要:ストアはモジュールレベルに存在します。ユーティリティ関数、イベントハンドラー、Node.jsのテスト環境など、アプリをラップすることなくどこでもインポートして使えます。
- 選択的サブスクリプション:コンポーネントはサブスクライブしている特定の状態が変わったときだけ再レンダリングされます。メモ化の曲芸は不要です。これだけでReduxダッシュボードにあった2つのパフォーマンス劣化を修正できました。
- 戦わなくていいTypeScript:Zustandストアの型付けは簡単です。Reduxのジェネリック型は、特にネストした状態の形状で本当に複雑になりがちです。
- バンドルサイズが小さい:Zustandはgzip圧縮後約1KBです。Redux Toolkitは約11KBあります。単独では致命的ではありませんが、積み重なります。
Zustandのデメリット
- DevToolsが自動ではない:
devtoolsミドルウェアを手動で追加します。ReduxのDevTools統合はすぐに使えて洗練されています——アクション履歴、差分表示、すべて揃っています。 - 構造が強制されない:自由さはトラップでもあります。チームの規約がなければ、コードベースが成長するにつれてストアがガラクタ置き場になりかねません。
- タイムトラベルデバッグ:ここではReduxがまだ勝っています。Zustandもdevtoolsミドルウェアでサポートしていますが、体験としてはまだ成熟していません。
- ミドルウェアエコシステムが小さい:Reduxにはロギング、永続化、Sagaベースのオーケストレーションなど深いミドルウェアサポートがあります。Zustandには
persistとimmerがあり、ほとんどのケースをカバーしますが、エコシステムは薄いです。
本番プロジェクトに推奨するセットアップ
6ヶ月間、3つの異なるプロジェクトで最もきれいに保てたセットアップがこれです。
インストール
npm install zustand
# イミュータブルな更新のために推奨(任意):
npm install immer
フォルダ構成
専用のstore/ディレクトリにストアを配置します。ドメインの概念ごとに1ファイル。すべてを単一のストアファイルに詰め込まないでください——6ヶ月後に400行の怪物ができあがります。
src/
store/
useAuthStore.ts
useCartStore.ts
useNotificationStore.ts
DevToolsの有効化
開発環境では常にストアのcreatorをdevtoolsミドルウェアでラップしてください。微妙な状態のバグを追いかけるときに必ず感謝することになります:
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const useAuthStore = create(devtools((set) => ({
user: null,
isAuthenticated: false,
login: (user) => set({ user, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
})))
export default useAuthStore
実装ガイド:基本から非同期まで
基本的なストア
まずはカウンターから始めましょう。単純すぎる?そうかもしれません。でも非同期やミドルウェアが登場する前に、完全なパターンをクリーンに示しています:
import { create } from 'zustand'
interface CounterState {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))
コンポーネントでの使い方:
import { useCounterStore } from '../store/useCounterStore'
export function Counter() {
const { count, increment, decrement } = useCounterStore()
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
)
}
非同期アクションとAPIコール
非同期にミドルウェアは不要です。アクションはただの関数なので、async/awaitがそのまま使えます:
import { create } from 'zustand'
interface User {
id: number
name: string
email: string
}
interface UserState {
users: User[]
isLoading: boolean
error: string | null
fetchUsers: () => Promise<void>
}
export const useUserStore = create<UserState>((set) => ({
users: [],
isLoading: false,
error: null,
fetchUsers: async () => {
set({ isLoading: true, error: null })
try {
const res = await fetch('/api/users')
const data = await res.json()
set({ users: data, isLoading: false })
} catch (err) {
set({ error: 'ユーザーの取得に失敗しました', isLoading: false })
}
},
}))
パフォーマンスのための選択的サブスクリプション
ストアから必要なものだけを取り出してください。各コンポーネントは、ストア全体ではなく、サブスクライブしている特定のスライスが変わったときだけ再レンダリングされます:
// `isLoading` が変わったときだけ再レンダリング
function LoadingIndicator() {
const isLoading = useUserStore((state) => state.isLoading)
return isLoading ? <Spinner /> : null
}
// `users` が変わったときだけ再レンダリング
function UserList() {
const users = useUserStore((state) => state.users)
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
ページリロードをまたいだ状態の永続化
組み込みのpersistミドルウェアがlocalStorageやsessionStorageへの同期を処理します。テーマの設定は典型的なユースケースです:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface ThemeState {
theme: 'light' | 'dark'
toggleTheme: () => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}),
{ name: 'theme-storage' } // localStorageのキー
)
)
React外でのストア状態の読み取り
私が最もよく使うパターンの1つ——axiosインターセプターやユーティリティ関数でフックなしにストアの状態にアクセスする方法です:
// すべての送信リクエストに認証トークンを付与
import { useAuthStore } from '../store/useAuthStore'
axios.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
それでもReduxを選ぶべき場面
ZustandはすべてのReduxのユースケースに対するそのままの代替品ではありません。10人以上の開発者がいる大規模アプリでは、Redux Toolkitの強制された規約と成熟したDevToolsはそのオーバーヘッドに見合います。Redux-Sagaでマルチステップの非同期フローをオーケストレーションしている場合——チェックアウトパイプライン、多段階フォーム送信、複雑なリトライロジックなど——Reduxのミドルウェアエコシステムはより深く対応しています。
それ以外のすべて——SPA、ダッシュボード、社内ツール、数十万行未満のほとんどの顧客向けアプリ——では、Zustandは能力を失わずに摩擦を取り除きます。6ヶ月の本番稼働、3つのプロジェクト、状態に関連したインシデントはゼロ。機能ごとに触るファイルが減り、新しいチームメンバーのオンボーディングが速くなり、信頼性は同じです。
小さく始めましょう。機能スライスを1つ移行してみてください。1週間後どう感じるか確認してみてください。それだけで判断は自然と明確になるはずです。

