zustand について
Zustand は、Hook ベースの API で状態を作成・参照する React 向け状態管理ライブラリです。このガイドではミドルウェア、非同期アクション、セレクター、Next.js と SSR のパターンも扱います。
主な機能
- 01Hook ベースの状態管理
- 02セレクターと購読
- 03Persist、DevTools、Immer ミドルウェア
- 04非同期アクションと楽観的更新
- 05スライスと TypeScript のパターン
- 06SSR のハイドレーションパターン
活用シーン
- 01React アプリのグローバル状態管理
- 02状態への永続化や DevTools の追加
- 03複雑な状態ロジックと非同期処理の整理
- 04Next.js アプリでの状態ハイドレーション
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ bobmatnyc/ claude-mpm-skills --skill zustandインストール後、Claude Code で/zustandを入力して読み込まれたか確認します。
同梱ファイルmetadata.json
詳細
Zustand 状態管理
概要
Zustand は、React 向けの最小限で意見を押し付けない状態管理ライブラリです。プロバイダーも定型コードも不要で、React アプリケーションで自然に使えるシンプルなフックベースの API を提供します。
使用する場面
- Redux の複雑さを避けながら、グローバル状態を必要とする React アプリ
- 定型コードとバンドルサイズを最小限に抑えたいプロジェクト
- リデューサーより直接的な状態変更を好むチーム
- 柔軟な状態のハイドレーションが必要な SSR アプリケーション(Next.js)
- Redux/Context API から、よりシンプルなソリューションへの移行
クイックスタート
npm install zustand
// stores/useCounterStore.ts
import { create } from 'zustand'
interface CounterState {
count: number
increment: () => void
decrement: () => void
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}))
// components/Counter.tsx
import { useCounterStore } from '@/stores/useCounterStore'
export function Counter() {
const { count, increment, decrement } = useCounterStore()
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
)
}
Zustand 完全ガイド
コアコンセプト
ストアの作成
import { create } from 'zustand'
// Basic store
interface BearState {
bears: number
addBear: () => void
}
const useBearStore = create<BearState>((set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
}))
// Store with get access
const useStore = create<State>((set, get) => ({
count: 0,
increment: () => {
const currentCount = get().count
set({ count: currentCount + 1 })
},
}))
状態へのアクセスパターン
// Select entire store (re-renders on any change)
const state = useStore()
// Select specific fields (re-renders only when these change)
const bears = useStore((state) => state.bears)
const addBear = useStore((state) => state.addBear)
// Destructure with selector
const { bears, addBear } = useStore((state) => ({
bears: state.bears,
addBear: state.addBear,
}))
// Multiple selectors
const bears = useStore((state) => state.bears)
const fish = useStore((state) => state.fish)
状態の変更
interface TodoState {
todos: Todo[]
addTodo: (text: string) => void
toggleTodo: (id: string) => void
removeTodo: (id: string) => void
}
const useTodoStore = create<TodoState>((set) => ({
todos: [],
// Add item
addTodo: (text) => set((state) => ({
todos: [...state.todos, { id: nanoid(), text, completed: false }]
})),
// Update item
toggleTodo: (id) => set((state) => ({
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
})),
// Remove item
removeTodo: (id) => set((state) => ({
todos: state.todos.filter(todo => todo.id !== id)
})),
}))
React との統合
useStore フック
function BearCounter() {
// Re-renders when bears changes
const bears = useBearStore((state) => state.bears)
return <h1>{bears} bears around here...</h1>
}
function Controls() {
// Doesn't re-render when bears changes
const addBear = useBearStore((state) => state.addBear)
return <button onClick={addBear}>Add bear</button>
}
シャロー比較
import { shallow } from 'zustand/shallow'
// Prevent re-renders when object identity changes but values don't
const { nuts, honey } = useBearStore(
(state) => ({ nuts: state.nuts, honey: state.honey }),
shallow
)
// Custom equality function
const treats = useBearStore(
(state) => state.treats,
(prev, next) => prev.length === next.length
)
React コンポーネントの外部での使用
// Read state
const count = useStore.getState().count
// Subscribe to changes
const unsubscribe = useStore.subscribe(
(state) => console.log('Count changed:', state.count)
)
// Update state
useStore.setState({ count: 42 })
// Update with function
useStore.setState((state) => ({ count: state.count + 1 }))
TypeScript のパターン
型付きストアの作成
interface UserState {
user: User | null
setUser: (user: User) => void
clearUser: () => void
}
const useUserStore = create<UserState>((set) => ({
user: null,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
}))
// Type inference works automatically
const user = useUserStore((state) => state.user) // User | null
ストア型の推論
// Extract store type
type UserStoreState = ReturnType<typeof useUserStore.getState>
// Selector type helper
type Selector<T> = (state: UserState) => T
const selectUsername: Selector<string | undefined> = (state) =>
state.user?.name
複数のストアの結合
// Type-safe store combination
function useHybridStore<T, U>(
selector1: (state: State1) => T,
selector2: (state: State2) => U
): [T, U] {
return [
useStore1(selector1),
useStore2(selector2),
]
}
const [user, theme] = useHybridStore(
(s) => s.user,
(s) => s.theme
)
スライスパターン
スライスの作成
// authSlice.ts
export interface AuthSlice {
user: User | null
login: (credentials: Credentials) => Promise<void>
logout: () => void
}
export const createAuthSlice: StateCreator<
AuthSlice & TodoSlice,
[],
[],
AuthSlice
> = (set) => ({
user: null,
login: async (credentials) => {
const user = await api.login(credentials)
set({ user })
},
logout: () => set({ user: null }),
})
// todoSlice.ts
export interface TodoSlice {
todos: Todo[]
addTodo: (text: string) => void
}
export const createTodoSlice: StateCreator<
AuthSlice & TodoSlice,
[],
[],
TodoSlice
> = (set) => ({
todos: [],
addTodo: (text) => set((state) => ({
todos: [...state.todos, { id: nanoid(), text, completed: false }]
})),
})
// store.ts
import { create } from 'zustand'
import { createAuthSlice, AuthSlice } from './authSlice'
import { createTodoSlice, TodoSlice } from './todoSlice'
export const useStore = create<AuthSlice & TodoSlice>()((...a) => ({
...createAuthSlice(...a),
...createTodoSlice(...a),
}))
スライス間の連携
export const createTodoSlice: StateCreator<
AuthSlice & TodoSlice,
[],
[],
TodoSlice
> = (set, get) => ({
todos: [],
addTodo: (text) => {
// Access other slice's state
const user = get().user
if (!user) throw new Error('Not authenticated')
set((state) => ({
todos: [...state.todos, {
id: nanoid(),
text,
userId: user.id,
completed: false
}]
}))
},
})
ミドルウェア
Persist ミドルウェア
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface PreferencesState {
theme: 'light' | 'dark'
language: string
setTheme: (theme: 'light' | 'dark') => void
}
export const usePreferencesStore = create<PreferencesState>()(
persist(
(set) => ({
theme: 'light',
language: 'en',
setTheme: (theme) => set({ theme }),
}),
{
name: 'preferences-storage', // localStorage key
storage: createJSONStorage(() => localStorage),
// Partial persistence
partialize: (state) => ({ theme: state.theme }),
// Migration between versions
version: 1,
migrate: (persistedState: any, version: number) => {
if (version === 0) {
// Migrate from v0 to v1
persistedState.language = 'en'
}
return persistedState as PreferencesState
},
}
)
)
// Custom storage (e.g., AsyncStorage for React Native)
const customStorage = {
getItem: async (name: string) => {
const value = await AsyncStorage.getItem(name)
return value ?? null
},
setItem: async (name: string, value: string) => {
await AsyncStorage.setItem(name, value)
},
removeItem: async (name: string) => {
await AsyncStorage.removeItem(name)
},
}
const useStore = create(
persist(
(set) => ({ /* ... */ }),
{
name: 'app-storage',
storage: createJSONStorage(() => customStorage)
}
)
)
DevTools ミドルウェア
import { devtools } from 'zustand/middleware'
interface CounterState {
count: number
increment: () => void
}
const useCounterStore = create<CounterState>()(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }), false, 'increment'),
}),
{
name: 'CounterStore',
enabled: process.env.NODE_ENV === 'development'
}
)
)
// Action names in Redux DevTools
set({ count: 42 }, false, 'setCount')
set((state) => ({ count: state.count + 1 }), false, { type: 'increment', amount: 1 })
Immer ミドルウェア
import { immer } from 'zustand/middleware/immer'
interface TodoState {
todos: Todo[]
addTodo: (text: string) => void
toggleTodo: (id: string) => void
}
const useTodoStore = create<TodoState>()(
immer((set) => ({
todos: [],
// Mutate state directly with Immer
addTodo: (text) => set((state) => {
state.todos.push({ id: nanoid(), text, completed: false })
}),
toggleTodo: (id) => set((state) => {
const todo = state.todos.find(t => t.id === id)
if (todo) todo.completed = !todo.completed
}),
}))
)
ミドルウェアの組み合わせ
const useStore = create<State>()(
devtools(
persist(
immer((set) => ({
// Store implementation
})),
{ name: 'app-storage' }
),
{ name: 'AppStore' }
)
)
非同期アクションと API の統合
基本的な非同期アクション
interface UserState {
users: User[]
loading: boolean
error: string | null
fetchUsers: () => Promise<void>
}
const useUserStore = create<UserState>((set) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null })
try {
const users = await api.getUsers()
set({ users, loading: false })
} catch (error) {
set({ error: error.message, loading: false })
}
},
}))
楽観的更新
interface TodoState {
todos: Todo[]
addTodo: (text: string) => Promise<void>
}
const useTodoStore = create<TodoState>((set, get) => ({
todos: [],
addTodo: async (text) => {
const tempId = `temp-${Date.now()}`
const optimisticTodo = { id: tempId, text, completed: false }
// Add optimistically
set((state) => ({ todos: [...state.todos, optimisticTodo] }))
try {
const savedTodo = await api.createTodo(text)
// Replace temp with real todo
set((state) => ({
todos: state.todos.map(t =>
t.id === tempId ? savedTodo : t
)
}))
} catch (error) {
// Rollback on error
set((state) => ({
todos: state.todos.filter(t => t.id !== tempId)
}))
throw error
}
},
}))
リクエストの重複排除
interface DataState {
data: Data | null
loading: boolean
fetchData: () => Promise<void>
}
let currentRequest: Promise<void> | null = null
const useDataStore = create<DataState>((set) => ({
data: null,
loading: false,
fetchData: async () => {
// Return existing request if in progress
if (currentRequest) return currentRequest
set({ loading: true })
currentRequest = api.getData()
.then((data) => {
set({ data, loading: false })
})
.catch((error) => {
set({ loading: false })
throw error
})
.finally(() => {
currentRequest = null
})
return currentRequest
},
}))
計算値(セレクター)
基本的なセレクター
interface TodoState {
todos: Todo[]
}
// Memoized with useCallback or outside component
const selectCompletedCount = (state: TodoState) =>
state.todos.filter(t => t.completed).length
const selectActiveCount = (state: TodoState) =>
state.todos.filter(t => !t.completed).length
function TodoStats() {
const completedCount = useTodoStore(selectCompletedCount)
const activeCount = useTodoStore(selectActiveCount)
return <div>{completedCount} / {activeCount + completedCount}</div>
}
ストア内の派生状態
interface TodoState {
todos: Todo[]
get completed(): Todo[]
get active(): Todo[]
get stats(): { total: number; completed: number; active: number }
}
const useTodoStore = create<TodoState>((set, get) => ({
todos: [],
get completed() {
return get().todos.filter(t => t.completed)
},
get active() {
return get().todos.filter(t => !t.completed)
},
get stats() {
const todos = get().todos
return {
total: todos.length,
completed: todos.filter(t => t.completed).length,
active: todos.filter(t => !t.completed).length,
}
},
}))
// Usage
const stats = useTodoStore((state) => state.stats)
パラメーター付きセレクター
// Create selector factory
const selectTodoById = (id: string) => (state: TodoState) =>
state.todos.find(t => t.id === id)
function TodoItem({ id }: { id: string }) {
const todo = useTodoStore(selectTodoById(id))
return <div>{todo?.text}</div>
}
パフォーマンスの最適化
サブスクリプションパターン
// Subscribe to specific state changes
useEffect(() => {
const unsubscribe = useTodoStore.subscribe(
(state) => state.todos,
(todos) => {
console.log('Todos changed:', todos)
}
)
return unsubscribe
}, [])
// Subscribe with selector and equality
const unsubscribe = useTodoStore.subscribe(
(state) => state.todos.length,
(length) => console.log('Todo count:', length),
{ equalityFn: (a, b) => a === b }
)
トランジェント更新
// Updates that don't trigger subscribers
interface ScrubbingState {
position: number
updatePosition: (pos: number) => void
}
const useScrubbingStore = create<ScrubbingState>((set) => ({
position: 0,
updatePosition: (pos) => set({ position: pos }, true), // true = transient
}))
// Subscribers won't be notified
useScrubbingStore.getState().updatePosition(50)
更新のバッチ処理
const useTodoStore = create<TodoState>((set) => ({
todos: [],
batchUpdate: (updates: Partial<TodoState>[]) => {
// Single re-render for multiple updates
set((state) => {
let newState = { ...state }
updates.forEach(update => {
newState = { ...newState, ...update }
})
return newState
})
},
}))
テスト戦略
モックストア
// __tests__/Counter.test.tsx
import { create } from 'zustand'
import { render, screen, fireEvent } from '@testing-library/react'
import { Counter } from '@/components/Counter'
import { useCounterStore } from '@/stores/useCounterStore'
// Mock the store
jest.mock('@/stores/useCounterStore')
describe('Counter', () => {
beforeEach(() => {
const mockStore = create<CounterState>((set) => ({
count: 0,
increment: jest.fn(() => set((state) => ({ count: state.count + 1 }))),
decrement: jest.fn(),
}))
useCounterStore.mockImplementation(mockStore)
})
it('increments count', () => {
render(<Counter />)
fireEvent.click(screen.getByText('+'))
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
})
テストユーティリティ
// test-utils.ts
import { create } from 'zustand'
export function createTestStore<T>(initialState: Partial<T>) {
return create<T>(() => initialState as T)
}
// Usage in tests
const testStore = createTestStore<TodoState>({
todos: [
{ id: '1', text: 'Test todo', completed: false }
]
})
テスト間でのストアのリセット
// stores/useCounterStore.ts
const initialState = { count: 0 }
export const useCounterStore = create<CounterState>((set) => ({
...initialState,
increment: () => set((state) => ({ count: state.count + 1 })),
reset: () => set(initialState),
}))
// __tests__/Counter.test.tsx
afterEach(() => {
useCounterStore.getState().reset()
})
移行ガイド
Redux からの移行
// Redux
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1 },
decrement: (state) => { state.value -= 1 },
},
})
// Zustand equivalent
const useCounterStore = create<CounterState>((set) => ({
value: 0,
increment: () => set((state) => ({ value: state.value + 1 })),
decrement: () => set((state) => ({ value: state.value - 1 })),
}))
// Redux usage
const dispatch = useDispatch()
const value = useSelector((state) => state.counter.value)
dispatch(increment())
// Zustand usage
const { value, increment } = useCounterStore()
increment()
Context API からの移行
// Context API
const ThemeContext = createContext<ThemeContextType>(null!)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light')
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export const useTheme = () => useContext(ThemeContext)
// Zustand equivalent (no provider needed!)
export const useThemeStore = create<ThemeState>((set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}))
// Usage is simpler
const { theme, setTheme } = useThemeStore()
Next.js との統合
App Router(RSC)
// stores/useCartStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useCartStore = create<CartState>()(
persist(
(set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
}),
{
name: 'cart-storage',
// Skip persistence on server
skipHydration: true,
}
)
)
// components/Cart.tsx (Client Component)
'use client'
import { useCartStore } from '@/stores/useCartStore'
import { useEffect } from 'react'
export function Cart() {
const { items, addItem } = useCartStore()
// Hydrate persisted state
useEffect(() => {
useCartStore.persist.rehydrate()
}, [])
return <div>{items.length} items</div>
}
Server Actions との統合
// actions/cart.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function syncCartToServer(items: CartItem[]) {
await db.cart.upsert({
where: { userId: 'current-user' },
update: { items },
create: { userId: 'current-user', items },
})
revalidatePath('/cart')
}
// stores/useCartStore.ts
export const useCartStore = create<CartState>((set) => ({
items: [],
addItem: async (item) => {
set((state) => ({ items: [...state.items, item] }))
// Sync to server
const items = useCartStore.getState().items
await syncCartToServer(items)
},
}))
SSR ハイドレーション
// app/layout.tsx
import { CartStoreProvider } from '@/providers/CartStoreProvider'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html>
<body>
<CartStoreProvider>
{children}
</CartStoreProvider>
</body>
</html>
)
}
// providers/CartStoreProvider.tsx
'use client'
import { useRef } from 'react'
import { useCartStore } from '@/stores/useCartStore'
export function CartStoreProvider({ children }: { children: ReactNode }) {
const initialized = useRef(false)
if (!initialized.current) {
// Initialize with server data if needed
useCartStore.setState({ items: [] })
initialized.current = true
}
return <>{children}</>
}
ベストプラクティス
ストアの構成
// ✅ Good: Single responsibility stores
const useAuthStore = create<AuthState>(...)
const useTodoStore = create<TodoState>(...)
const useUIStore = create<UIState>(...)
// ❌ Bad: God store
const useAppStore = create<AppState>(...)
アクションの命名
// ✅ Good: Clear, verb-based actions
const useStore = create((set) => ({
addTodo: (text) => set(...),
removeTodo: (id) => set(...),
toggleTodo: (id) => set(...),
}))
// ❌ Bad: Vague or noun-based
const useStore = create((set) => ({
todo: (text) => set(...), // What does this do?
update: (id) => set(...), // Update what?
}))
セレクターの最適化
// ✅ Good: Specific selectors
const user = useStore((state) => state.user)
const theme = useStore((state) => state.theme)
// ❌ Bad: Selecting entire store
const state = useStore() // Re-renders on any change
エラー処理
// ✅ Good: Explicit error state
interface State {
data: Data | null
loading: boolean
error: Error | null
fetchData: () => Promise<void>
}
// ❌ Bad: Silent failures
const fetchData = async () => {
try {
const data = await api.getData()
set({ data })
} catch (error) {
// Error silently ignored
}
}
よくあるパターン
読み込み状態
interface ResourceState<T> {
data: T | null
loading: boolean
error: Error | null
status: 'idle' | 'loading' | 'success' | 'error'
}
function createResourceStore<T>() {
return create<ResourceState<T>>((set) => ({
data: null,
loading: false,
error: null,
status: 'idle',
fetch: async () => {
set({ loading: true, status: 'loading', error: null })
try {
const data = await fetchData()
set({ data, loading: false, status: 'success' })
} catch (error) {
set({ error, loading: false, status: 'error' })
}
},
}))
}
元に戻す/やり直す
interface HistoryState<T> {
past: T[]
present: T
future: T[]
set: (state: T) => void
undo: () => void
redo: () => void
}
function createHistoryStore<T>(initialState: T) {
return create<HistoryState<T>>((set) => ({
past: [],
present: initialState,
future: [],
set: (newPresent) => set((state) => ({
past: [...state.past, state.present],
present: newPresent,
future: [],
})),
undo: () => set((state) => {
if (state.past.length === 0) return state
const previous = state.past[state.past.length - 1]
const newPast = state.past.slice(0, -1)
return {
past: newPast,
present: previous,
future: [state.present, ...state.future],
}
}),
redo: () => set((state) => {
if (state.future.length === 0) return state
const next = state.future[0]
const newFuture = state.future.slice(1)
return {
past: [...state.past, state.present],
present: next,
future: newFuture,
}
}),
}))
}
他の選択肢との比較
Redux との比較
Zustand のメリット:
- 定型コードが不要(アクション、リデューサー、ディスパッチが不要)
- プロバイダーが不要
- バンドルサイズが小さい(約 1kb 対 約 20kb)
- 非同期処理がよりシンプル
- TypeScript の型推論がそのまま使える
Redux のメリット:
- タイムトラベルデバッグ
- より大きなエコシステムとミドルウェア
- 厳格な単方向データフロー
- 非常に大規模なアプリケーションに適している
Context API との比較
Zustand のメリット:
- プロバイダー地獄がない
- パフォーマンスが優れている(サブツリー全体が再レンダリングされない)
- API がよりシンプル
- ミドルウェアが組み込まれている
Context のメリット:
- React に組み込まれている(依存関係が不要)
- コンポーネントローカルな状態に適している
- コンポーネントの境界が明示的
Jotai との比較
Zustand のメリット:
- より従来的なストアベースのアプローチ
- 複雑な状態ロジックに適している
- Redux からの移行が簡単
Jotai のメリット:
- アトミックな状態管理
- コード分割に適している
- より React らしい(アトムベース)
- Suspense をそのままサポート
リソース
関連スキル
Zustand を使用する際、以下のスキルがワークフローを強化します:
- react:Zustand ストア向けの React 統合パターンとフック
- tanstack-query:サーバー状態管理(クライアント状態には Zustand と併用)
- nextjs:Next.js App Router および Client Components での Zustand の使用
- test-driven-development:Zustand ストア、アクション、セレクターのテスト
[これらのスキルがバンドルに含まれている場合、完全なドキュメントを利用できます]
コメント
Markdown 対応