Zustandとは?Reactの状態管理を最小コードで実現する使い方を徹底解説【v5対応】

Reactアプリの状態管理ライブラリを探していて「Reduxは大げさだが、もっと手軽に使えるものはないか」と感じたことはないでしょうか。Zustand(ツーシュタント)はそんな場面にぴったりの、最小限のコードでグローバルな状態を扱える軽量ライブラリです。この記事では、インストールから createuseStoreset の基本、TypeScript対応、Sliceパターン、非同期処理、永続化、Next.js(SSR)での注意点、そして2024年に登場したv5の変更点までを、動くコード例を交えて解説します。

まとめ:Zustandは「最小APIで状態管理を完結できる」軽量ライブラリ

先に要点をまとめます。Zustandは、Provider不要・ボイラープレートほぼゼロで、create 関数ひとつに状態とアクションをまとめて定義し、useStore フックで必要な値だけを取り出して使う状態管理ライブラリです。外部依存がほぼなくコアはgzip圧縮後おおよそ1KB前後と非常に小さく、セレクタによって「使っている値が変わったときだけ」再レンダリングされるため、パフォーマンスも確保しやすいのが特長です。小〜中規模はもちろん、SliceパターンとTypeScriptを併用すれば中〜大規模でも通用します。導入を判断するうえでの結論は次のとおりです。

  • とにかく手軽に状態を共有したいなら第一候補。学習コストが低く、React Hooksの知識だけで始められます。
  • クライアント側のUI状態に向いています。サーバーから取得するデータの管理は、後述のTanStack Queryのような専用ライブラリと役割を分けると整理しやすくなります。
  • 2024年10月にv5が登場。default exportの廃止やReact 18必須化などの変更があるため、新規導入なら最初からv5前提で書くのがおすすめです。

以降では、この結論の根拠となる具体的な使い方をコードとともに見ていきます。

Zustandとは?読み方・特徴・人気の理由

ZustandはReact向けの状態管理ライブラリで、「Zustand」はドイツ語で「状態」を意味します。読み方はドイツ語風の「ツーシュタント」、英語圏では「ズースタンド」と呼ばれることもあり、発音は一定していません。Reduxの流れをくむトップダウン型で、atom中心のJotaiとは兄弟のような関係にあります。

最大の特徴は、Provider不要・ボイラープレートがほとんどないシンプルさです。Reduxのようにaction・reducer・storeを分けて記述する必要がなく、create の中に状態と更新関数をまとめて書くだけで動きます。さらに外部依存がほぼなく(v5ではReact 18の useSyncExternalStore を直接利用)、ReactのContext APIではなく独自のサブスクリプション機構を使うため、コアはgzip圧縮後でおよそ1KB前後と軽量です。GitHubのスター数は約5.8万に達し、Reactの状態管理ライブラリとして定番の選択肢になっています。

インストールと最小のストア作成

インストール手順(npm / yarn / pnpm)

導入はパッケージを追加するだけで、特別な初期設定は不要です。

# npm
npm install zustand
 
# yarn
yarn add zustand
 
# pnpm
pnpm add zustand

v5はReact 18以降を必須とします。React 16.8〜17で使う場合はv4系を選ぶか、Reactのバージョンを上げてください。

createでストアを定義する

ストアは create 関数で作成します。引数のコールバックは set を受け取り、状態の初期値とアクション(更新関数)をまとめたオブジェクトを返します。下はカウンターの例です。

import { create } from 'zustand'
 
const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}))

ポイントは、戻り値が「ストアフック」になることです。慣例として useXxxStore という名前を付け、ストアの定義はモジュールスコープで一度だけ行います。

useStoreで状態を取得する(セレクタが重要)

定義したストアフックは、コンポーネント内でそのまま呼び出して使います。引数にセレクタ関数を渡し、必要な値だけを取り出すのが基本です。

function Counter() {
  // 必要な値・関数だけを個別に取り出す
  const count = useCounterStore((state) => state.count)
  const increment = useCounterStore((state) => state.increment)
 
  return <button onClick={increment}>count: {count}</button>
}

セレクタで絞ることで、選択した値が変化したときだけそのコンポーネントが再レンダリングされます。逆に useCounterStore() のように引数なしで全体を取得すると、ストア内のどの値が変わっても再描画されてしまうため、パフォーマンス上は避けるのが無難です。

set関数による状態更新の基本

状態の更新は set 関数で行います。Reactの useState と同じく、既存の状態を直接書き換えず、新しいオブジェクトを返して更新するのが原則です。set は渡したオブジェクトを既存の状態に浅くマージします。

// 関数形式:前の状態に依存する更新(推奨)
set((state) => ({ count: state.count + 1 }))
 
// オブジェクト形式:値を直接指定(浅いマージ)
set({ count: 0 })
 
// 複数の状態を1回のsetでまとめて更新
set((state) => ({ count: state.count + 1, loading: false }))
 
// ネストしたオブジェクトはスプレッドで新しい参照を作る
set((state) => ({ user: { ...state.user, name: 'Taro' } }))

深い階層を更新するときは、内側のオブジェクトもスプレッドで新しい参照を作る必要があります。これを怠ると参照が変わらず、Reactが変更を検知できずUIに反映されないことがあります。ネストが深くなりすぎる場合は、後述のSliceパターンで状態を分割するか、immerミドルウェア(zustand/middleware/immer)の利用を検討してください。

再レンダリングを抑えるセレクタとuseShallow

1つのセレクタで複数の値をまとめて取り出したい場面では注意が必要です。次のようにオブジェクトを返すと、毎回新しいオブジェクトが生成されるため、中身が同じでも参照が変わり、不要な再レンダリングが起こります。

これを防ぐのが useShallow です。選択結果を浅く比較し、中身が変わらなければ再レンダリングを抑制します。

import { useShallow } from 'zustand/react/shallow'
 
function Status() {
  const { count, loading } = useCounterStore(
    useShallow((state) => ({ count: state.count, loading: state.loading })),
  )
  return <p>{loading ? '更新中…' : count}</p>
}

v5では create 自体がカスタム等価関数(shallow など)を受け取れなくなりました。等価関数をセレクタに直接渡したい場合は、zustand/traditionalcreateWithEqualityFn を使います。新規コードでは、上記の useShallow を使う書き方が標準的です。なお2025年10月に正式版が出たReact Compilerは自動メモ化により手動最適化の負担を減らしますが、ストア購読の粒度を決めるセレクタの設計は引き続き重要です。

TypeScriptで型安全に使う

TypeScriptで使う場合は、まず状態とアクションの型を定義し、createカレー化(curried)した形 create<State>()(...) で呼び出します。create<State>((set) => ...) ではなく、() をはさむ点に注意してください。これはTypeScriptの型推論の制約に対応するための書き方で、公式でも推奨されています。

import { create } from 'zustand'
 
interface CounterState {
  count: number
  increment: () => void
  reset: () => void
}
 
const useCounterStore = create<CounterState>()((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  reset: () => set({ count: 0 }),
}))

型を明示しておくと、存在しないプロパティへのアクセスやタイプミスをコンパイル時に検出でき、エディタの補完も効くようになります。

Sliceパターンでストアを分割する

機能が増えてストアが肥大化してきたら、関心ごとに「スライス」へ分割します。各スライスを (set, get) => ({ ... }) 形式の関数として定義し、最後に create でまとめます。TypeScriptでは各スライスの型を交差型(&)で結合します。

import { create, StateCreator } from 'zustand'
 
interface CountSlice {
  count: number
  increment: () => void
}
interface TextSlice {
  text: string
  setText: (text: string) => void
}
type Store = CountSlice & TextSlice
 
const createCountSlice: StateCreator<Store, [], [], CountSlice> = (set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
})
const createTextSlice: StateCreator<Store, [], [], TextSlice> = (set) => ({
  text: '',
  setText: (text) => set({ text }),
})
 
const useStore = create<Store>()((...a) => ({
  ...createCountSlice(...a),
  ...createTextSlice(...a),
}))

スライスごとにファイルを分けておけば、機能追加や差分管理がしやすく、各スライス単位でのテストも書きやすくなります。これにより、中〜大規模アプリでもストアの見通しを保てます。

非同期処理とAPI連携

Zustandの非同期処理は、特別なミドルウェアなしで async/await をそのまま使えます。Reduxのthunkやsagaのような追加導入は不要です。ローディングやエラーの状態もストアで一元管理すると、UIと素直に連動させられます。

interface User { id: string; name: string }
 
interface UserState {
  user: User | null
  loading: boolean
  error: string | null
  fetchUser: (id: string) => Promise<void>
}
 
const useUserStore = create<UserState>()((set) => ({
  user: null,
  loading: false,
  error: null,
  fetchUser: async (id) => {
    set({ loading: true, error: null })
    try {
      const res = await fetch(`/api/users/${id}`)
      const user = await res.json()
      set({ user, loading: false })
    } catch {
      set({ error: '取得に失敗しました', loading: false })
    }
  },
}))

なお、ここで扱っているのは「サーバーから取得したデータ」です。キャッシュや再取得、ページネーションなど本格的なサーバー状態管理が必要なら、Zustandで自作するよりTanStack Query(旧React Query)のような専用ライブラリに任せ、Zustandはクライアント側のUI状態に専念させると役割が明確になります。

ミドルウェア:persistによる永続化とdevtools

Zustandは create の定義をラップする形でミドルウェアを追加できます。代表的なのが、状態を localStorage などに保存して再読み込み後も復元する persist と、Redux DevToolsで状態変化を可視化できる devtools です。両者はチェーンして併用できます。

import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'
 
const useSettingsStore = create(
  devtools(
    persist(
      (set) => ({
        theme: 'light',
        toggleTheme: () =>
          set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
      }),
      { name: 'settings-storage' }, // localStorageのキー名(一意にする)
    ),
  ),
)

persistname はlocalStorage上のキー名になるため、アプリ内で一意になるよう命名します。v5では persist の挙動が変わり、ストア生成時には値を保存しなくなりました(最初の更新時から保存されます)。v4から移行する場合は、この差分に注意してください。

Next.js(SSR)で使うときの注意点

ZustandはNext.jsなどのサーバーサイドレンダリング環境でも使えますが、いくつか注意点があります。まず、Zustandのフックはクライアントコンポーネントでのみ動作します。Next.jsのApp Routerでは、ストアを使うコンポーネントの先頭に 'use client' を付けてください。

'use client'
import { create } from 'zustand'
 
// モジュールスコープで一度だけ定義する
export const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
}))

サーバーで状態を持たせると、リクエストをまたいで値が共有されてしまう恐れがあります。リクエストごとに初期化したい場合は、モジュールスコープのグローバルなストアではなく、Provider+ファクトリ関数でリクエスト単位のストアを生成する設計が推奨されます。また persist を使うときは window が存在しないサーバー側を考慮し、クライアントでの復元(ハイドレーション)タイミングにも気を配ると安全です。

他の状態管理ライブラリとの比較(Redux・Recoil・Jotai)

主要な状態管理ライブラリと比べると、Zustandの立ち位置は「軽量・低学習コストで、必要十分な機能を備えた中庸の選択肢」です。下表は目安としての比較です(サイズはgzip圧縮後のおおよその値)。

ライブラリ 設計 Provider ボイラープレート サイズ(目安) 学習コスト
Zustand フック・ストア 不要 約1KB
Redux Toolkit Flux・action/reducer 必要 約10〜20KB
Recoil atom/selector 必要 約20KB
Jotai atom中心 任意 約3KB 低〜中

※上表のサイズはbundlephobia等の計測に基づくおおよその目安で、計測対象(コア単体かReactバインディング込みか)やバージョンによって変動します。Zustandはコア単体での計測だと約0.6KBとさらに小さくなります。

Reduxは状態の追跡やデバッグが強力で大規模に向く一方、記述量が多くなりがちです。Recoilはatom/selectorのモデルを切り拓きましたが、2025年1月にMetaがリポジトリをアーカイブし、メンテナンスが終了しています。新規プロジェクトでの採用は避けるのが無難で、atomモデルを使いたい場合は後継的な位置づけのJotaiが選ばれる傾向です。ZustandとJotaiはどちらも軽量ですが、Zustandは「1つのストアに状態をまとめる」トップダウン型、Jotaiは「小さなatomを組み合わせる」ボトムアップ型という違いがあります。

Zustand v5の主な変更点

Zustand v5は2024年10月20日にリリースされ、現在は5.0.x系が最新です。新機能の追加よりも、古い仕様の整理が中心の「掃除」的なメジャーアップデートで、v4からの移行は比較的スムーズとされています。主な変更点は次のとおりです。

  • default exportの廃止import create from 'zustand' は不可。import { create } from 'zustand' の名前付きインポートに統一。
  • React 18・TypeScript 4.5 を最低要件化。React 18必須化により use-sync-external-store パッケージを廃止し、ネイティブの useSyncExternalStore を利用するようになりました。
  • create のカスタム等価関数サポートを廃止shallow 等を使うなら useShallowzustand/traditionalcreateWithEqualityFn へ。
  • persist の挙動変更:ストア生成時には保存せず、最初の更新時から保存。
  • UMD/SystemJS・ES5サポートの廃止など、その他の整理。

変動の速い仕様もあるため、最新の正確な情報は公式ドキュメントで確認することをおすすめします。

よくある質問(FAQ)

Q. Zustandの読み方は?

ドイツ語で「状態」を意味し、「ツーシュタント」や「ズースタンド」と読まれます。発音は地域や話者によって異なり、一つに定まってはいません。

Q. ReduxとZustandはどちらを使うべき?

学習コストを抑えて素早く状態管理を導入したいならZustandが向いています。厳密な変更履歴の追跡や大規模チームでの統制が必要ならReduxにも利点があります。多くの中小規模アプリではZustandで十分です。

Q. 複数のストアは作っていい?

作って構いません。用途別に小さく分ける方法と、1つのストアをSliceパターンで分割する方法があり、後者は型の統合や共通処理の共有がしやすいのが利点です。

Q. 非同期処理に専用のミドルウェアは必要?

不要です。アクションを async 関数として定義し、await の結果を set で反映するだけで動きます。

Q. Next.jsのサーバーコンポーネントで使える?

Zustandのフックはクライアント専用です。使うコンポーネントに 'use client' を付け、サーバーコンポーネントでは直接呼び出さないようにします。

関連記事

資料請求

RELATED POSTS 関連記事