はじめに
結論から言うと、Zustandは2024年以降のReact状態管理でかなり有力な選択肢になっています。
GitHubで56,000スター以上、毎月5,500万回以上のnpmダウンロード。もはや「新興ライブラリ」とは呼べない規模ですね。
正直なところ、最初は「また新しい状態管理ライブラリかよ」と思っていたんですよ。Reduxで頑張ってきた身としては、乗り換えるのも面倒だし。でも実際に使い始めたら、「なんでもっと早く試さなかったんだ」という気持ちになりました。30代になって思うのは、技術選定で「今までこれでやってきたから」は危険な思考停止だということ。
Zustandとは
Zustandはドイツ語で「状態」を意味する言葉で、pmndrs(ポモンドールズ)というチームが開発したReact向けの状態管理ライブラリです。
「Bear necessities for state management」がキャッチコピーで、必要最小限の機能だけを提供するという思想があります。熊のマスコットが特徴的ですね。
現在の最新バージョンはv5.0.9で、MITライセンスのオープンソースとして公開されています。7年以上の歴史があり、324人以上のコントリビューターが開発に参加しています。
特徴・メリット
1. とにかく軽い
これ、意外と重要なんですけど、Zustandのバンドルサイズは約3.55kB(minified + gzipped)。Reduxとミドルウェア込みだと数十kBになることを考えると、圧倒的に軽い。
コスパ的に、この軽さは正義です。特にパフォーマンスを気にするプロジェクトでは。
2. Providerが不要
Reduxだと<Provider store={store}>でアプリ全体をラップする必要がありますよね。Zustandはそれが不要。ストアを作って、コンポーネントでhookを呼ぶだけ。
個人的には、この「設定の少なさ」が一番のメリットだと思います。
3. ボイラープレートが少ない
Reduxのaction、reducer、dispatchの三点セットに疲れた人、多いと思うんですよ。
ZustandはcreateでストアをつくってsetでState更新、以上。この直感的な書き方がQOL上がります。
4. TypeScriptとの相性が良い
型推論がしっかり効くので、TypeScriptで書くときのストレスがない。Storeの型定義も簡潔に書けます。
5. React以外の問題にも対応
zombie child問題、React Concurrent Modeでの問題、異なるレンダラー間でのコンテキスト喪失など、状態管理で起きがちな問題に対処済み。この辺は地味だけど、実際に困ったことがある人には刺さる話。
インストール方法
前提条件
Reactプロジェクトがセットアップされている必要があります。
インストール
npm install zustand
これだけ。追加のミドルウェアとか、dev toolsとか、別パッケージのインストールは不要です。
TypeScriptサポート
型定義は内蔵されているので、@types/zustandみたいな追加パッケージは不要。時短になる。
基本的な使い方
ストアの作成
シンプルなカウンターストアを作ってみます。
import { create } from 'zustand'
interface CounterState {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
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 }),
}))
これだけでストア完成。Reduxのあの長いセットアップを考えると、驚くほどシンプル。
コンポーネントでの使用
function Counter() {
const { count, increment, decrement, reset } = useCounterStore()
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
)
}
普通のhookと同じ感覚で使える。学習コストがほぼゼロなんですよ。
セレクターによる最適化
必要な状態だけを取得することで、無駄な再レンダリングを防げます。
function CountDisplay() {
// countだけを購読。increment関数が変わっても再レンダリングしない
const count = useCounterStore((state) => state.count)
return <p>Count: {count}</p>
}
function CountButtons() {
// 関数だけを購読。countが変わっても再レンダリングしない
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)
return (
<div>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
</div>
)
}
これ、パフォーマンス最適化が簡単にできるのがいい。
非同期処理
interface TodoState {
todos: Todo[]
isLoading: boolean
error: string | null
fetchTodos: () => Promise<void>
}
const useTodoStore = create<TodoState>((set) => ({
todos: [],
isLoading: false,
error: null,
fetchTodos: async () => {
set({ isLoading: true, error: null })
try {
const response = await fetch('/api/todos')
const todos = await response.json()
set({ todos, isLoading: false })
} catch (error) {
set({ error: 'Failed to fetch', isLoading: false })
}
},
}))
async/awaitをそのまま使える。redux-thunkとかredux-sagaとか、ミドルウェアを入れる必要がない。
実践的なユースケース
ユーザー認証状態の管理
interface AuthState {
user: User | null
isAuthenticated: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
}
const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
login: async (email, password) => {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
const user = await response.json()
set({ user, isAuthenticated: true })
},
logout: () => set({ user: null, isAuthenticated: false }),
}))
UIの状態管理
モーダルやサイドバーの開閉状態など、グローバルに管理したいUI状態。
interface UIState {
isSidebarOpen: boolean
isModalOpen: boolean
modalContent: React.ReactNode | null
toggleSidebar: () => void
openModal: (content: React.ReactNode) => void
closeModal: () => void
}
const useUIStore = create<UIState>((set) => ({
isSidebarOpen: false,
isModalOpen: false,
modalContent: null,
toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
openModal: (content) => set({ isModalOpen: true, modalContent: content }),
closeModal: () => set({ isModalOpen: false, modalContent: null }),
}))
ショッピングカート
interface CartItem {
id: number
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
addItem: (item: Omit<CartItem, 'quantity'>) => void
removeItem: (id: number) => void
updateQuantity: (id: number, quantity: number) => void
clearCart: () => void
totalPrice: () => number
}
const useCartStore = create<CartState>((set, get) => ({
items: [],
addItem: (item) => set((state) => {
const existing = state.items.find((i) => i.id === item.id)
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
}
}
return { items: [...state.items, { ...item, quantity: 1 }] }
}),
removeItem: (id) => set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) => set((state) => ({
items: state.items.map((i) =>
i.id === id ? { ...i, quantity } : i
),
})),
clearCart: () => set({ items: [] }),
totalPrice: () => {
const { items } = get()
return items.reduce((total, item) => total + item.price * item.quantity, 0)
},
}))
get()を使えば、現在の状態を参照した計算も簡単。
persistミドルウェア
ローカルストレージへの永続化も標準でサポート。
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useSettingsStore = create(
persist<SettingsState>(
(set) => ({
theme: 'light',
language: 'ja',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'settings-storage', // ローカルストレージのキー
}
)
)
リロードしても状態が保持される。便利。
Reduxからの移行
個人的には、Reduxから移行する価値は十分あると思います。
移行のポイント:
-
まず小さなストアから始める
- 既存のReduxはそのまま、新機能をZustandで書く
-
ストアを分割する
- Reduxの巨大なstoreを、Zustandでは複数の小さなstoreに分ける
-
ミドルウェアは必要なものだけ
- devtools、persist、immerくらいで十分なことが多い
import { devtools } from 'zustand/middleware'
const useStore = create(
devtools(
(set) => ({
// ...store definition
}),
{ name: 'MyStore' }
)
)
Redux DevToolsもそのまま使えるので、デバッグ環境は維持できます。
まとめ
Zustandを導入して感じた変化:
- セットアップ: 5分で完了。Reduxの数十行の設定が不要に
- 学習コスト: ほぼゼロ。hooksが分かれば即使える
- バンドルサイズ: 3.55kBで超軽量
- 開発速度: ボイラープレートが減って体感1.5倍速くなった
- TypeScript: 型推論がしっかり効いてストレスフリー
正直なところ、新規プロジェクトでReduxを選ぶ理由はかなり限られると思います。複雑なミドルウェアが必要とか、超大規模チームで厳格なルールが必要とか、そういう場合以外はZustand一択ですね。
特にシンプルさと軽量さを重視するプロジェクトには最適。「状態管理って面倒」と思っていた人ほど、Zustandの良さが分かると思います。
まだ試していない人は、まず小さなプロジェクトで使ってみてください。Reduxで消耗していた時間を取り戻せますよ。
