Zustand 実践メモ

Zustand は、React の外側に置いたストアをフックから購読する状態管理ライブラリである。 ここでは機能の表面だけでなく、実装から分かる更新規則、購読の単位、SSR 時の境界を確認する。

1. Zustand は何を解決するのか

Zustand の中心は「React コンポーネントの外にストアを置き、コンポーネントはセレクタで必要な部分だけ購読する」という形である。 create が返すものは React フックであり、同時に getState, setState, subscribe, getInitialState を持つストアでもある。

観点Zustand の性質設計上の意味
提供元コンポーネント通常は不要単一ページアプリでは、ストアを読み込むだけで使える
購読単位セレクタの戻り値コンポーネントごとに必要な値だけを読む設計に向く
更新set / setStatereducer 固定ではなく、更新関数をストアに置くことが多い
React 連携useSyncExternalStoreReact 18 以降の外部ストア購読の仕組みに乗る
素のストアReact なしでも作れるContext による注入、React 外の処理、テストで使いやすい

「グローバル状態を全部入れる場所」ではなく、「複数のコンポーネントから同じ可変状態を参照し、更新と購読を明示的に管理したい場所」と考える方が事故が少ない。 React の局所状態、URL、サーバーデータのキャッシュ、フォーム管理まで Zustand に集める必要はない。

2. 基本的な使用方法

まずは create でストアを作り、コンポーネントではセレクタで必要な値や更新関数だけを取り出す。 Provider で囲む必要はなく、作ったフックを import して呼ぶだけで使える。

npm install zustand

# pnpm の場合
pnpm add zustand
import { create } from 'zustand'

type CounterState = {
  count: number
  increase: () => void
  reset: () => void
}

export const useCounterStore = create<CounterState>()((set) => ({
  count: 0,
  increase: () => set((state) => ({ count: state.count + 1 })),
  reset: () => set({ count: 0 }),
}))

React コンポーネントでは、ストア全体ではなく表示や操作に必要なものを選んで読む。 更新関数も同じフックから取り出して、イベントハンドラで呼べばよい。

function Counter() {
  const count = useCounterStore((state) => state.count)
  const increase = useCounterStore((state) => state.increase)
  const reset = useCounterStore((state) => state.reset)

  return (
    <div>
      <p>count: {count}</p>
      <button onClick={increase}>+1</button>
      <button onClick={reset}>Reset</button>
    </div>
  )
}

コンポーネント外から現在値を読む場合は getState、外部イベントを購読する場合は subscribe を使える。 ただし、画面に表示する値はフックで購読し、外部購読では解除関数を管理する。

const currentCount = useCounterStore.getState().count

const unsubscribe = useCounterStore.subscribe((state, prevState) => {
  if (state.count !== prevState.count) {
    console.log('count changed:', state.count)
  }
})

unsubscribe()

基本形は「create で状態と更新関数を定義する」「コンポーネントではセレクタで読む」「イベントでは更新関数を呼ぶ」の三つである。 その上で、再レンダリングや SSR の境界が必要になったら後続の章を確認する。

3. コア実装から分かること

Zustand の素のストアは概念的には次の構造である。 実装では Set に購読関数を持ち、setState が新しい状態を作って購読関数に現在の状態と直前の状態を渡す。

type StoreApi<T> = {
  setState: {
    (partial: T | Partial<T> | ((state: T) => T | Partial<T>), replace?: false): void
    (state: T | ((state: T) => T), replace: true): void
  }
  getState: () => T
  getInitialState: () => T
  subscribe: (listener: (state: T, prevState: T) => void) => () => void
}

重要なのは setState の既定動作である。 replace を明示しない通常更新では、次の値がオブジェクトの場合に Object.assign({}, state, nextState) 相当の浅いマージになる。 深いマージではないため、入れ子のオブジェクトは自分で不変更新する必要がある。

type AuthState = {
  user: { id: string; name: string } | null
  status: 'anonymous' | 'loading' | 'authenticated'
  actions: {
    setUser: (user: AuthState['user']) => void
    rename: (name: string) => void
  }
}

const useAuthStore = create<AuthState>()((set) => ({
  user: null,
  status: 'anonymous',
  actions: {
    setUser: (user) => set({ user, status: user ? 'authenticated' : 'anonymous' }),
    rename: (name) =>
      set((state) => ({
        user: state.user ? { ...state.user, name } : null,
      })),
  },
}))

set({ user: ... }) はルートの状態だけを浅くマージする。 user.name だけを暗黙にマージしてくれるわけではない。 入れ子の更新が多いストアでは、状態形状を浅くするか、公式ミドルウェアの immer を検討する。

4. セレクタと再レンダリング

React 側の useStore は内部で React.useSyncExternalStore を使い、selector(api.getState()) の結果をスナップショットとして扱う。 セレクタの戻り値が変わったと判断されると、コンポーネントは再レンダリングされる。 v5 の標準 create では比較関数を直接渡せないため、戻り値は基本的に Object.is で比較されるものとして考える。

4-1. 小さいセレクタを基本にする

const userName = useAuthStore((state) => state.user?.name)
const status = useAuthStore((state) => state.status)
const rename = useAuthStore((state) => state.actions.rename)

プリミティブ値や安定した関数参照を個別に取るセレクタは扱いやすい。 ストア初期化時に作った更新関数は、その関数自体を差し替えない限り同じ参照を保つ。

4-2. オブジェクトや配列を毎回作るセレクタは注意する

// 更新のたびに新しいオブジェクトを返す
const view = useAuthStore((state) => ({
  name: state.user?.name,
  status: state.status,
}))

このセレクタは呼ばれるたびに新しいオブジェクトを返す。 値の中身が同じでも参照は異なるため、不要な再レンダリングや v5 での不安定なスナップショット問題につながり得る。 複数値をまとめたい場合は useShallow を使う。

import { useShallow } from 'zustand/react/shallow'

const view = useAuthStore(
  useShallow((state) => ({
    name: state.user?.name,
    status: state.status,
  })),
)

v5 移行ガイドでは、セレクタが新しい参照を返す場合に無限ループになり得る例も示されている。 代替関数をセレクタ内で毎回作るようなコードも同じ問題を持つ。

const NOOP = () => {}

const onSubmit = useFormStore((state) => state.onSubmit ?? NOOP)

5. ストアの形と更新関数の置き方

Zustand は reducer を強制しない。 実務では状態と更新関数を同じストアに置き、コンポーネントからは更新関数を呼ぶだけにする形が読みやすい。 ただし更新関数をどこに置くかは、セレクタの安定性と永続化の対象に影響する。

type Todo = { id: string; title: string; done: boolean }

type TodoStore = {
  todos: Record<string, Todo>
  order: string[]
  actions: {
    add: (title: string) => void
    toggle: (id: string) => void
    remove: (id: string) => void
  }
}

export const useTodoStore = create<TodoStore>()((set) => ({
  todos: {},
  order: [],
  actions: {
    add: (title) => {
      const id = crypto.randomUUID()
      set((state) => ({
        todos: { ...state.todos, [id]: { id, title, done: false } },
        order: [...state.order, id],
      }))
    },
    toggle: (id) =>
      set((state) => {
        const todo = state.todos[id]
        if (!todo) return {}
        return {
          todos: { ...state.todos, [id]: { ...todo, done: !todo.done } },
        }
      }),
    remove: (id) =>
      set((state) => {
        const { [id]: _removed, ...todos } = state.todos
        return { todos, order: state.order.filter((todoId) => todoId !== id) }
      }),
  },
}))

上の例では更新関数を actions にまとめている。 これは必須ではないが、persistpartialize で状態だけを保存しやすく、コンポーネント側でも useTodoStore((s) => s.actions.add) のように選びやすい。

5-1. スライス分割は実装分割であってストア分散ではない

公式ドキュメントの slices pattern は、複数の作成関数を展開して一つの結合済みストアを作る方法である。 ミドルウェアは個別スライスではなく、結合したストアに適用するのが公式の注意点である。

const createUserSlice = (set) => ({
  user: null,
  setUser: (user) => set({ user }),
})

const createProjectSlice = (set, get) => ({
  projects: {},
  clearUserProjects: () => {
    const user = get().user
    if (user) set({ projects: {} })
  },
})

export const useAppStore = create((...args) => ({
  ...createUserSlice(...args),
  ...createProjectSlice(...args),
}))

6. 型付け

TypeScript では create<State>()((set, get) => ...) の二段呼び出し形式を使うのが基本である。

type SessionState = {
  token: string | null
  expiresAt: number | null
  actions: {
    setSession: (token: string, expiresAt: number) => void
    clear: () => void
  }
}

export const useSessionStore = create<SessionState>()((set) => ({
  token: null,
  expiresAt: null,
  actions: {
    setSession: (token, expiresAt) => set({ token, expiresAt }),
    clear: () => set({ token: null, expiresAt: null }),
  },
}))

v5 では setStatereplace: true に対する型が厳しくなっている。 replace: true はルートの状態を完全に置き換える指定なので、部分オブジェクトを渡す用途には使わない。

// 通常の浅いマージ
useSessionStore.setState({ token: 'new-token' })

// 完全置換。actions も含めて完全な状態が必要
useSessionStore.setState(
  {
    token: null,
    expiresAt: null,
    actions: useSessionStore.getState().actions,
  },
  true,
)

7. ミドルウェアは必要なものだけ足す

Zustand のミドルウェアはストア作成関数を包む関数である。 代表的には persist, devtools, immer, subscribeWithSelector, redux がある。 どれも便利だが、ストアの意味を変えるため、導入理由を明確にする。

ミドルウェア用途注意点
persistlocalStorage 等に状態を保存するpartialize, version, migrate を設計する
devtoolsRedux DevTools に更新を出す更新名を付けないと追跡しにくい
immer入れ子の更新を下書きへの代入として書くImmer の実行コストとルールを受け入れる
subscribeWithSelectorReact 外でセレクタ付き購読をする購読解除の管理が必要

7-1. persist は保存対象を絞る

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'system',
      draftSearchQuery: '',
      actions: {
        setTheme: (theme) => set({ theme }),
        setDraftSearchQuery: (draftSearchQuery) => set({ draftSearchQuery }),
      },
    }),
    {
      name: 'settings-v1',
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ theme: state.theme }),
      version: 1,
    },
  ),
)

persist は既定で localStorage を使う。 一時的な入力値、通信由来の古くなりやすいデータ、認可上慎重に扱うべき値を雑に保存しない。 公式ドキュメントでは skipHydration が SSR 用の選択肢として説明されている。

8. React での境界設計

8-1. コンポーネントでは読み取りを細くする

コンポーネントで useStore() とセレクタなしに呼ぶと、そのコンポーネントはストア全体を購読する。 画面が大きくなるほど、関係ない更新で再レンダリングされる範囲が広がる。

// 避けたい: ストアのどこが変わっても再レンダリング候補になる
const state = useTodoStore()

// よい: このコンポーネントが必要なものだけ読む
const todo = useTodoStore((state) => state.todos[id])
const toggle = useTodoStore((state) => state.actions.toggle)

8-2. イベント処理内での非購読の読み取り

getState はコンポーネント外やイベント処理で現在値を読む逃げ道として使える。 ただし描画中に使う値なら、フック経由で購読する。

function SaveButton() {
  const save = async () => {
    const { todos } = useTodoStore.getState()
    await saveTodos(Object.values(todos))
  }

  return <button onClick={save}>Save</button>
}

8-3. React Context と素のストア

Zustand は通常、提供元コンポーネントを必要としない。ただし、ストアを props で初期化したい場合、テストで差し替えたい場合、Next.js 等でリクエストごとにストアを分けたい場合は Context が有効である。 公式ドキュメントではフックそのものを Context に入れるのではなく、createStore で作った素のストアと useStore(store, selector) を組み合わせる方法が示されている。

import { createContext, useContext, useState } from 'react'
import { createStore, useStore } from 'zustand'

const CounterStoreContext = createContext<ReturnType<typeof createCounterStore> | null>(null)

function CounterStoreProvider({ children }: { children: React.ReactNode }) {
  const [store] = useState(() => createCounterStore({ count: 0 }))
  return (
    <CounterStoreContext.Provider value={store}>
      {children}
    </CounterStoreContext.Provider>
  )
}

function useCounter<T>(selector: (state: CounterStore) => T): T {
  const store = useContext(CounterStoreContext)
  if (!store) throw new Error('CounterStoreProvider is missing')
  return useStore(store, selector)
}

9. SSR / Next.js での注意点

Zustand のストアはモジュール状態になり得る。 クライアントだけで動く単一ページアプリならそれが利点になるが、サーバーが複数リクエストを処理する環境では、リクエスト間で状態を共有しない設計が必要である。 公式の Next.js ガイドも「ストアはリクエストごとに作る」「React Server Components はストアを読んだり書いたりしない」と説明している。

Server Component からグローバルなストアを読む設計は避ける。 ユーザー固有の値がモジュール状態に残ると、リクエスト間共有やハイドレーション不一致の原因になる。

ハイドレーションではサーバーとクライアントの初期出力が一致する必要がある。 localStorage を読む persist ストア、現在時刻、乱数、ブラウザ専用 API に依存する値は、初期描画に混ぜると不一致を作りやすい。 SSR 対応が必要なストアは、初期値を明示的に渡す、クライアントのマウント後に復元する、またはそもそもサーバー描画の表示条件から外す。

SSR 環境でのストア境界 サーバー上のグローバルストアはリクエスト間で共有され得るため、リクエストごとにストアを作る。 SSR では「どこに状態が残るか」を分けて考える 避ける: サーバー上のグローバルストア リクエスト A と B が同じモジュール状態を見る可能性がある ユーザー固有の値を置くと混線の原因になる リクエスト A リクエスト B リクエスト A initA リクエスト B initB ストア A createStore(initA) ストア B createStore(initB) Context で注入 useStore(store, selector) 要点: ユーザー別、リクエスト別の状態は、モジュール上の一つのストアに置かない。

10. 実務でのベストプラクティス

  1. ストアに入れる前に、React の局所状態、URL、サーバーデータのキャッシュ、フォーム管理のどれかで足りないか確認する。
  2. コンポーネントではセレクタを必ず書く。ストア全体を読むコンポーネントを増やさない。
  3. セレクタはプリミティブ値、安定参照、または useShallow で安定化したオブジェクトや配列を返す。
  4. 更新関数はストアに置き、コンポーネントからは更新関数を呼ぶ。更新処理をコンポーネントに散らさない。
  5. ルートの状態は浅い構造を優先する。深いオブジェクトを頻繁に更新するなら正規化や immer を検討する。
  6. persist は保存対象を partialize で絞り、形式変更に備えて versionmigrate を設計する。
  7. SSR ではモジュール上のグローバルストアをユーザー別、リクエスト別の状態に使わない。ストア作成関数と Context で境界を作る。
  8. React 外の購読は必ず解除関数を管理する。長寿命の処理で購読を放置するとリークになる。
  9. replace: true は完全置換として扱う。部分更新のつもりで使わない。
  10. ストアを巨大化させすぎない。スライス分割は「一つのストアを保ったまま実装を分ける」ために使う。

11. よくある失敗

失敗何が起きるか対策
useStore() を広く使う関係ない更新で再レンダリングされるセレクタを書く
セレクタで毎回オブジェクトや配列を作る参照が毎回変わる小さいセレクタか useShallow
サーバーでグローバルストアにユーザー状態を置くリクエスト間共有の危険があるリクエストごとに素のストアを作る
永続化に更新関数や一時状態も含める不要な復元、古い値、移行負荷が出るpartialize で保存対象を限定
Zustand をサーバーデータのキャッシュ代わりにする再取得、古さ判定、重複排除、失敗時の再試行を自前実装することになるTanStack Query 等のサーバーデータ用ライブラリを使う

12. Zustand を使わない方がよい場面

Zustand は小さいが、万能な状態置き場ではない。 通信結果のキャッシュ、ページング、再試行、古さ判定、更新後の再取得が中心ならサーバーデータ用ライブラリの領域である。 複雑なフォーム検証とフィールド単位の変更管理が中心ならフォームライブラリの領域である。 URL と共有される絞り込みやタブは URLSearchParams の方がよい場合が多い。

Zustand がよく合うのは、画面横断のクライアント状態、複数コンポーネント間で共有する編集・セッション・作業領域の状態、React 外のイベントからも更新される状態、提供元コンポーネントの階層を増やしたくない単一ページアプリの状態である。 逆に「全部 Zustand に寄せる」設計は、責務が見えにくくなる。

Appendix. API・実装チートシート

create

zustand から import。React フック付きストアを作る入口。戻り値は getState, setState, subscribe, getInitialState も持つ。

createStore

zustand/vanilla から import。React から独立した素のストア。Context 注入、テスト、React 外、SSR のリクエスト単位ストアに向く。

useStore

zustand から import。useStore(store, selector) で素のストアを React から読む。描画に必要な値だけを購読する。

createWithEqualityFn

zustand/traditional から import。セレクタ比較関数を使いたい場合の入口。利用には use-sync-external-store が必要。

useShallow

zustand/react/shallow から import。複数値をオブジェクトや配列でまとめる時に、浅い比較で前回参照を再利用する。

shallow

zustand/vanilla/shallow から import。単体の浅い比較関数。深い構造の同値判定ではなく、トップレベルの比較に使う。

set / setState

状態を更新して購読者へ通知する。通常はルートだけ浅くマージする。replace: true は完全置換なので完全な状態を渡す。

get / getState / subscribe

現在値の非購読読み取りと React 外の購読に使う。描画に使う値はフックで読む。subscribe の解除関数は必ず管理する。

ミドルウェア早見表

persist

zustand/middleware から import。ストレージ保存と復元。partialize, version, migrate, hydration 方針を先に決める。

devtools

zustand/middleware から import。Redux DevTools に更新履歴を出す。ストア名、アクション名、動的ストアの cleanup を意識する。

immer

zustand/middleware/immer から import。入れ子の不変更新を代入風に書く。draft の扱いと更新頻度に対するコストを見る。

subscribeWithSelector

zustand/middleware から import。React 外で特定スライスだけを購読する。解除、比較関数、fireImmediately の要否を決める。

combine

zustand/middleware から import。初期状態から型推論しつつ action を足す。型明示のストア設計と読みやすさを比べる。

redux

zustand/middleware から import。reducer と dispatch の形に寄せる。既存 Redux 的設計を残す必要がある時向け。

実装の要点は、setState が状態を差し替え、subscribe の listener 群へ現在値と直前値を通知し、React 側はセレクタ結果をスナップショットとして購読する、という流れである。 したがって「どの値を購読するか」「更新後も参照が安定するか」「購読をいつ解除するか」が性能と安全性の中心になる。

参考