mcpField
Z
zustand

zustand

github.com/bobmatnyc開発78491 回インストール15 週間前に更新
リポジトリ

zustand について

Zustand は、Hook ベースの API で状態を作成・参照する React 向け状態管理ライブラリです。このガイドではミドルウェア、非同期アクション、セレクター、Next.js と SSR のパターンも扱います。

主な機能

  1. 01Hook ベースの状態管理
  2. 02セレクターと購読
  3. 03Persist、DevTools、Immer ミドルウェア
  4. 04非同期アクションと楽観的更新
  5. 05スライスと TypeScript のパターン
  6. 06SSR のハイドレーションパターン

活用シーン

  1. 01React アプリのグローバル状態管理
  2. 02状態への永続化や DevTools の追加
  3. 03複雑な状態ロジックと非同期処理の整理
  4. 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 ストア、アクション、セレクターのテスト

[これらのスキルがバンドルに含まれている場合、完全なドキュメントを利用できます]

コメント