Zustand(ツーシュタント)は、Reactの状態をProviderなしで共有できる軽量な状態管理ライブラリです。2026年10月22日公開の5.0.16が最新で、v5ではセレクタの書き方を誤ると無限ループで画面が止まるなど、v4時代の解説記事のままでは動かない箇所があります。この記事では、インストールからストア作成、useShallow、TypeScript、Sliceパターン、persistによる永続化、Next.js App Routerでの使い方までを、手元で実行した結果(再描画回数・バンドルサイズ・保存内容)と公式ドキュメントの記述をもとに説明します。
まとめ:Zustand 5.0.16の要点と採用判断
Zustandは create 1つで状態と更新関数を定義し、コンポーネントからはセレクタで必要な値だけを読む作りです。最小構成の create だけなら、esbuildでminify後のgzipサイズは421バイトでした。
- 版:最新は5.0.16。React 18以上・TypeScript 4.5以上が必要です。v5.0.xはパッチ更新だけで、5.0.9で実験的な
unstable_ssrSafeが入りました。 - セレクタ:1つの値を返すセレクタはそのまま書き、複数の値をオブジェクトや配列で返すときは
useShallowで包みます。v5では包まないと「Maximum update depth exceeded」で止まります。 - persist:v5はストアを作った時点では保存せず、最初の更新で初めて書き込みます。保存する項目は
partializeで絞り、トークンなどの秘密情報は入れません。 - Next.js:App Routerではモジュールに置いたグローバルストアを使わず、リクエストごとにストアを作ってContextで配ります。Server Componentsからは読み書きしません。
- 選び方:クライアント側のUI状態を少ないコードで共有したいならZustandが第一候補です。サーバーから取ってくるデータのキャッシュはTanStack Queryに任せ、Zustandに自作しないほうが整理しやすくなります。
Zustandの意味と読み方・設計の特徴
「Zustand」はドイツ語で「状態」を意味する名詞です。ドイツ語版Wiktionaryの発音表記は [ˈt͡suːˌʃtant] で、日本語では「ツーシュタント」が近い読みです。読み方について、公式リポジトリのDiscussionでメンテナーのdai-shi氏は「作者はドイツ語風でも英語風でもこだわらない」と答えており、作者のdrcmda氏も「zoostand」などどの読み方でも構わないと書いています。日本では英語風の「ズースタンド」も使われます。開発元はJotaiやValtio、React Three Fiberと同じpmndrs(Poimandres)です。
設計上の特徴は、ストアがReactの外にあるという点です。公式READMEが挙げるとおり、アプリ全体をContext Providerで包む必要がなく、ストアのフックをどのコンポーネントからでも直接呼べます。React Contextは値が変わると、その値を読む全コンポーネントが再描画されます。Zustandはセレクタで選んだ値が変わったコンポーネントだけを再描画するので、更新の多い状態を共有しても影響範囲が広がりません。Contextだけで足りる場面との分け方はReact Contextの使い方|createContext・useContext・Providerの実装と再レンダー対策【React 19.3対応】で解説しています。
Reactバインディングが使うのは react パッケージの useSyncExternalStore・useCallback・useDebugValue で(5.0.16のsrc/react.ts)、peerDependenciesに react-dom を含みません。このためReact Nativeでも同じストア定義が動きます。WebとモバイルでReactのコードを共有する構成は、弊社のFlutter / React Native開発でも扱っています。
Zustandのインストールコマンドとバージョン要件(npm・pnpm・yarn)
追加の初期設定はなく、パッケージを入れればすぐ使えます。
npm install zustand
# pnpm add zustand
# yarn add zustand
# 入った版の確認
npm ls zustand
5.0.16のpackage.jsonが要求する依存は次のとおりです(npmのメタデータで確認)。peerDependenciesはすべてoptional指定で、使う機能に応じて入れます。
| 依存 | 要求範囲 | 必要になる場面 |
|---|---|---|
| react | >=18.0.0 | Reactのフックとして使うとき |
| @types/react | >=18.0.0 | TypeScriptで使うとき |
| immer | >=9.0.6 | immerミドルウェアを使うとき |
| use-sync-external-store | >=1.2.0 | zustand/traditionalを使うとき |
v5はReact 18以上を前提とするため、React 16.8〜17のプロジェクトではv4系を使うか、Reactを18以上に上げます。npmに公開されているv4系の最新は2025年5月15日公開の4.5.7です(npmレジストリの公開履歴で確認)。
createによるストア作成とコンポーネントでの購読
createによる状態とアクションの定義
create に渡す関数は set を受け取り、初期状態と更新関数をまとめたオブジェクトを返します。戻り値はフックなので、慣例として useXxxStore と名付け、モジュールで1回だけ定義します。以降のコード例はTypeScriptで書き、TypeScript 7.0.2の strict で型エラーが出ないことを確認しています。
import { create } from 'zustand'
type CounterState = {
count: number
user: { name: string; age: number }
increment: () => void
reset: () => void
setAge: (age: number) => void
}
export const useCounterStore = create<CounterState>()((set) => ({
count: 0,
user: { name: 'Taro', age: 20 },
increment: () => set((state) => ({ count: state.count + 1 })),
reset: () => set({ count: 0 }),
// set は1階層目だけを浅くマージする。ネストは自分でコピーする
setAge: (age) => set((state) => ({ user: { ...state.user, age } })),
}))
コンポーネントでは、セレクタ関数を渡して必要な値だけを取り出します。
function Counter() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
return <button onClick={increment}>{count}</button>
}
setの浅いマージとネストした状態のコピー
set は渡したオブジェクトを現在の状態に1階層だけマージします。user.age のような2階層目を変えるときに set({ user: { age } }) と書くと、user.name が消えます。上の setAge のようにスプレッドで内側もコピーしてください。ネストが深くなる場合は、immer を入れて zustand/middleware/immer の immer ミドルウェアで包むと、state.user.age = age のように直接書き換える記法で更新できます。
セレクタによる再描画の制御(useShallowとv5の無限ループ)
実測:セレクタの書き方別の再描画回数
3通りの読み方をするコンポーネントを並べ、ストアの別の値だけを更新したときの描画回数をReact 19.3.1で数えました。jsdom上でStrictModeを使わずに実行し、表の数字はマウントからの累計回数です。
| 読み方 | 初回 | 無関係な値を更新 | countを更新 |
|---|---|---|---|
| (s) => s.count | 1 | 1(描画なし) | 2 |
| useShallow で count と name | 1 | 1(描画なし) | 2 |
| 引数なし useCounterStore() | 1 | 2(再描画) | 3 |
引数なしで呼ぶとストア全体を購読するので、そのコンポーネントが使っていない値の更新でも再描画されます。画面に出す値ごとにセレクタを書くのが基本です。
新しい参照を返すセレクタとv5の無限ループの原因
複数の値をまとめて取り出そうとして、セレクタからオブジェクトを返すと問題が起きます。
// v5 では描画が止まる
const { count, name } = useCounterStore((state) => ({
count: state.count,
name: state.user.name,
}))
セレクタが呼ばれるたびに新しいオブジェクトが作られ、Reactは「値が変わった」と判断して再描画を繰り返します。手元の実行では、まず「The result of getSnapshot should be cached to avoid an infinite loop」の警告が出て、続けて「Maximum update depth exceeded」の例外で描画が止まりました。v5の移行ガイドも、セレクタが新しい参照を返すと無限ループになりうると明記しています。v4では等価判定の仕組みが違ったため、v4時代の記事のコードをそのまま写すとここで止まります。
直し方は useShallow で包むことです。useShallowの公式リファレンスのとおり、結果を浅く比較し、中身が同じなら前回と同じ参照を返します。
import { useShallow } from 'zustand/react/shallow'
export function Profile() {
// 単一の値はそのまま返す
const count = useCounterStore((state) => state.count)
// 複数の値をまとめて返すなら useShallow で包む(v5 では必須)
const { name, age } = useCounterStore(
useShallow((state) => ({ name: state.user.name, age: state.user.age })),
)
return <p>{name}({age}): {count}</p>
}
オブジェクトに限らず、state.items.filter(...) のように配列を作り直すセレクタや、state.action ?? (() => {}) のように毎回新しい関数を返すセレクタも同じ原因で止まります。フォールバックの関数はモジュールの定数にしておけば参照が固定されます。
createWithEqualityFnを使う場面
v5の create は独自の等価関数を受け取りません。v4で create に shallow を渡していたコードを移行するときや、新しいコードでも浅い比較では足りない独自の比較(値の誤差を許すなど)をセレクタごとに使いたいときは、zustand/traditional の createWithEqualityFn を使います。こちらは use-sync-external-store を別途インストールする必要があります。比較が浅い比較で済むなら、追加の依存が要らない useShallow で足ります。
TypeScriptでのストア型定義とcreate<T>()() の理由
TypeScriptでは create<CounterState>()((set) => ...) と、型引数のあとに空の () をはさみます。公式の上級TypeScriptガイドによると、TypeScriptは型引数を「全部指定するか全く指定しないか」しか選べないため、状態の型だけを指定してミドルウェア関係の型引数を推論させる回避策としてカリー化しています。() を省くとミドルウェアを重ねたときに型が崩れます。
例外は combine や redux のように、ミドルウェア自身が状態を作る場合です。この場合は状態の型を推論できるので、同ガイドはカリー化しない書き方を勧めています。
Sliceパターンによるストアの分割と結合
機能が増えたら、状態とアクションを関心ごとの「スライス」に分け、最後に1つのストアへ結合します。公式のSliceパターンのガイドは、ミドルウェアは結合したストアにだけ掛け、個々のスライスの中では使わないよう注意しています。スライス内で使うと予期しない動作につながるためです。
import { create, type StateCreator } from 'zustand'
import { persist } from 'zustand/middleware'
type BearSlice = { bears: number; addBear: () => void }
type FishSlice = { fishes: number; addFish: () => void }
type Store = BearSlice & FishSlice
const createBearSlice: StateCreator<Store, [['zustand/persist', unknown]], [], BearSlice> = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
})
const createFishSlice: StateCreator<Store, [['zustand/persist', unknown]], [], FishSlice> = (set) => ({
fishes: 0,
addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})
// ミドルウェアは結合したストアにだけ掛ける
export const useBoundStore = create<Store>()(
persist((...a) => ({ ...createBearSlice(...a), ...createFishSlice(...a) }), { name: 'bound-store' }),
)
StateCreator の第2型引数には、外側で掛けるミドルウェアを書きます。スライスの中で第3引数のストアAPIからミドルウェアの機能(persistなら persist.rehydrate など)を呼ぶときに型を合わせるためのもので、この例のように set だけを使うスライスなら、空配列にしても型チェックは通りました。スライスごとにファイルを分けると、機能単位でテストを書きやすくなります。
非同期アクションとReact外からの読み書き
非同期処理に専用のミドルウェアは要りません。アクションを async 関数にし、待ったあとに set を呼びます。ローディングとエラーも同じストアに持たせると、UIの分岐を書きやすくなります。
import { create } from 'zustand'
type Todo = { id: number; title: string }
type TodoState = {
todos: Todo[]
loading: boolean
error: string | null
fetchTodos: () => Promise<void>
}
export const useTodoStore = create<TodoState>()((set) => ({
todos: [],
loading: false,
error: null,
fetchTodos: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
set({ todos: (await res.json()) as Todo[], loading: false })
} catch (e) {
set({ error: (e as Error).message, loading: false })
}
},
}))
// React の外(API クライアントやイベントハンドラ)からも読み書きできる
const unsubscribe = useTodoStore.subscribe((state, prev) => {
if (state.error && state.error !== prev.error) console.warn('取得失敗:', state.error)
})
useTodoStore.getState().fetchTodos().finally(unsubscribe)
ストアのフックには getState・setState・subscribe が付いていて、コンポーネントの外からも使えます。subscribe のコールバックは新旧の状態を受け取り、戻り値の関数で購読を解除します。zustand/vanilla の createStore で作ったストアで試すと、2回更新したあとに解除したところ、コールバックの記録は「0->1, 1->10」の2件で、解除後の更新は届きませんでした。
キャッシュ、再取得、ページ単位の読み込みまで必要になったら、それはサーバー状態の管理です。Zustandで自作せず、TanStack Queryとは?React Queryとの違い・v5の使い方と既定値の落とし穴で紹介しているライブラリに任せ、Zustandはクライアント側のUI状態に絞ると役割がはっきりします。
persistによるlocalStorageへの保存と復元
v5のpersistの保存タイミング(生成時と初回更新時の実測)
persist ミドルウェアで包むと、状態を localStorage などに保存し、再読み込み後に復元します。v5(と4.5.5以降)では、ストアを作った時点では何も書き込みません。jsdomのlocalStorageで確かめると、生成直後の getItem は null で、1回更新したあとに {"state":{"count":1},"version":0} が保存されました。v4のように「初期値を必ず保存しておく」前提のコードは、ストア生成の直後に setState を1回呼ぶよう移行ガイドが案内しています。
partialize・version・migrateの書き方
import { create } from 'zustand'
import { persist, createJSONStorage, devtools } from 'zustand/middleware'
type SettingsState = {
theme: 'light' | 'dark'
token: string | null
setTheme: (theme: 'light' | 'dark') => void
}
export const useSettingsStore = create<SettingsState>()(
devtools(
persist(
(set) => ({
theme: 'light',
token: null,
setTheme: (theme) => set({ theme }),
}),
{
name: 'settings',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ theme: state.theme }), // token は保存しない
version: 1,
migrate: (persisted, version) => {
const s = persisted as { theme?: string; darkMode?: boolean }
if (version === 0) return { theme: s.darkMode ? 'dark' : 'light' } as SettingsState
return persisted as SettingsState
},
},
),
),
)
nameはlocalStorageのキー名です。アプリ内で重複しない名前にします。partializeで保存する項目を選びます。上の例でtokenを設定しても、保存内容は{"state":{"theme":"dark"},"version":1}のままでした。localStorageはページ上の任意のスクリプトから読めるため、認証トークンは保存対象から外します。- 保存データの形を変えたら
versionを上げ、migrateで旧形式を変換します。旧形式{"darkMode":true}(version 0)を置いた状態で読み込むとthemeはdarkに変換され、ストレージもversion 1の形で書き直されました。migrateを書かずにversionだけ上げると、persistのリファレンスのとおり古い保存データは使われません。
保存先をIndexedDBにしたい、容量が気になるといった場合の選び方はIndexedDBのデメリットと容量上限|localStorage・Cookieとの違いと使い分けにまとめています。
SSRでの復元タイミングの制御(skipHydration)
サーバーにはlocalStorageがないため、サーバーで描いたHTMLは初期値、ブラウザは保存値で描画し、内容がずれるとハイドレーションエラーになります。skipHydration: true を指定すると自動の復元が止まるので、マウント後に useEffect の中で useSettingsStore.persist.rehydrate() を呼びます。復元が終わったかは persist.hasHydrated() で分かりますが、これは購読を伴わない関数で、値が変わってもコンポーネントは再描画されません。表示を切り替えたいときは onRehydrateStorage のコールバックか、persist.onFinishHydration で自前の状態を更新します。エラー文の読み方はハイドレーションエラー(Hydration Error)とは?React 19のエラー文・#418の読み方と原因別の直し方で扱っています。
ミドルウェアの重ね順(devtools・persist・immer)
上のpersistの例で devtools を一番外に置いているのには理由があります。上級TypeScriptガイドは、devtools は setState を書き換えて型引数を足すため、ほかのミドルウェアより後(外側)に置くよう勧めています。immerと併用するなら devtools(immer(...)) で、immer(devtools(...)) ではありません。devtools はブラウザ拡張のRedux DevToolsに状態の変化を送るもので、使い方はRedux DevToolsの使い方|React-Reduxの接続とステートの追跡を参考にしてください。devtools の enabled オプションは既定で開発モードでは true、本番モードでは false です。明示しなければ本番ビルドで状態が拡張機能へ送られることはありませんが、enabled: true を書くと本番でも送られるので、条件を付けずに true を指定しないでください。
Next.js App Routerでのリクエスト単位のストア
公式が勧めるcreateStore+Contextの構成
App Routerでは2つの制約があります。サーバーは複数のリクエストを同時に処理するので、モジュールに置いたグローバルストアだと別ユーザーのリクエストと状態が混ざるおそれがあります。またServer Componentsはフックを使えません。公式のNext.jsガイドは「グローバルストアを作らない」「Server Componentsからストアを読み書きしない」の2点を推奨し、zustand/vanilla の createStore で作ったストアをContextで配る構成を示しています。
'use client'
import { type ReactNode, createContext, useContext, useState } from 'react'
import { useStore } from 'zustand'
import { createStore } from 'zustand/vanilla'
export type CounterStore = { count: number; increment: () => void }
export const createCounterStore = (initCount = 0) =>
createStore<CounterStore>()((set) => ({
count: initCount,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
type CounterStoreApi = ReturnType<typeof createCounterStore>
const CounterStoreContext = createContext<CounterStoreApi | undefined>(undefined)
export function CounterStoreProvider({ children, initCount }: { children: ReactNode; initCount?: number }) {
// リクエスト(ツリー)ごとに1回だけストアを作る
const [store] = useState(() => createCounterStore(initCount))
return <CounterStoreContext.Provider value={store}>{children}</CounterStoreContext.Provider>
}
export function useCounterStore<T>(selector: (store: CounterStore) => T): T {
const store = useContext(CounterStoreContext)
if (!store) throw new Error('useCounterStore must be used within CounterStoreProvider')
return useStore(store, selector)
}
useState の初期化関数で作るので、再描画のたびにストアが作り直されることはありません。Server Componentで取得した値を initCount として渡せば、サーバーとブラウザの初期値をそろえられます。ガイドのApp Routerの例はProviderを layout.tsx に置いています。ページ(ルート)ごとにストアを作る構成も取れますが、ページ単位でストアを作って配る手間が増えるため、ガイドは必要な場合以外は避けるよう注記しています。App Router自体の動かし方はNext.jsとは?16系App Routerで最小アプリを動かす手順と採用判断で解説しています。
unstable_ssrSafe(5.0.9の実験的ミドルウェア)
5.0.9のリリースノートで、Next.jsでの新しい使い方に向けた実験的ミドルウェア unstable_ssrSafe が追加されました。ストア定義を包むと、window がない環境をSSRと見なし、その間の set と setState を例外にします。手元で試すと、SSR扱いのストアで更新した時点で「Cannot set state of Zustand store in SSR」が投げられ、ブラウザ扱いでは通常どおり更新できました。サーバー側での書き込みを禁じれば、Contextなしのグローバルストアでもリクエスト間で状態が混ざらない、という考え方です。名前のとおり不安定版扱いで、Next.jsガイド冒頭にも「議論をもとに近く更新する」との注記があります。本番は上のContext構成で組み、ssrSafeは検証用にとどめるのが安全です。
Redux Toolkit・Jotai・Contextとの比較と選び方
サイズは、アプリ全体ではなく、表に書いたAPIだけをimportする最小のエントリファイルを測った値です。esbuild 0.28.2で --bundle --minify --format=esm --platform=browser --external:react --external:react-dom と process.env.NODE_ENV を production に置き換えてバンドルし、出力を gzip -9c(標準入力経由のためファイル名はヘッダーに入りません)で圧縮したバイト数です。冒頭の421バイトも同じ手順の値です。週間ダウンロード数はnpm公式のダウンロード数APIで2026年10月22日〜28日を指定して2026年10月31日に取得した値を、1万未満で四捨五入したものです(Zustandは85,871,516、Jotaiは9,073,758、@reduxjs/toolkitは45,970,844、Recoilは604,304)。
| ライブラリ(版) | 状態の単位 | Provider | gzip後 | 週間DL |
|---|---|---|---|---|
| Zustand 5.0.16(createのみ) | 1つのストア | 不要 | 421B | 8,587万 |
| Zustand(+persist・devtools・useShallow) | 1つのストア | 不要 | 3,158B | 同上 |
| Jotai 3.0.1(atom・useAtom) | 小さなatom | 任意 | 3,220B | 907万 |
| Redux Toolkit 2.13.0+react-redux 9.3.1 | slice+reducer | 必要 | 10,575B | 4,597万 |
| Recoil | atom・selector | 必要 | 測定対象外 | 60万 |
Recoilは2025年1月1日にMetaがGitHubリポジトリをアーカイブし、更新が止まっています。新規採用の候補から外してください。
選び方は次のように考えます。
- Zustandを選ぶ:ログイン中ユーザー、モーダルの開閉、カートなど、複数の画面で読むクライアント状態を少ない記述で共有したい場合。ストアが1か所にまとまるので、状態の全体像を追いやすくなります。
- Jotaiを選ぶ:フォームの各項目のように、細かい状態を部品ごとに持ち、派生値を組み合わせたい場合。違いはJotaiとは?atomで組むReact状態管理の使い方とv3の変更点【3.0.1対応】で詳しく比べています。
- Redux Toolkitを選ぶ:大人数で更新の書き方を統一したい、RTK Queryまで含めて1つの流儀にそろえたい場合。Redux Toolkitとは?createSliceとRTK Queryの実装と採用判断【2026年8月版】が判断材料になります。
Zustandを使わないほうがよいのは、状態がフォーム1画面の中で閉じている場合と、サーバーから取ったデータを持ち回るだけの場合です。前者は useState で足り、後者はTanStack Queryのキャッシュをそのまま読めばよく、ストアに写すと二重管理になります。
v4からv5へ移行するときの変更点
v5.0.0は2024年10月に公開され、新機能より古い仕様の削除が中心です。移行ガイドが挙げる変更のうち、コードの修正が必要になりやすいものは次のとおりです。
- default exportの廃止:
import create from 'zustand'は使えず、import { create } from 'zustand'に書き換えます。 - React 18・TypeScript 4.5が最低要件になり、
use-sync-external-storeはzustand/traditionalを使うときだけのpeerDependencyになりました。 createが等価関数を受け取らなくなり、新しい参照を返すセレクタは無限ループの原因になります(上の「セレクタによる再描画の制御」の章)。- persistが生成時に初期状態を保存しなくなりました(4.5.5も同じ)。
setStateの第2引数replaceにtrueを渡すときの型が厳しくなりました。UMD・SystemJS・ES5向けのビルドは提供されなくなりました。
5.0.xのパッチは主にpersistとdevtoolsの修正です。5.0.10で同時に呼んだ rehydrate の競合、5.0.12で復元後コールバックが古い状態を受け取る不具合、5.0.15で clearStorage() と非同期の復元が競合する不具合が直っています(GitHubのリリース一覧)。persistを使っているなら、5.0.0のまま止めずに最新の5.0.16へ上げてください。
よくある質問
Zustandの読み方は?
ドイツ語で「状態」を意味する語で、ドイツ語の発音に近いのは「ツーシュタント」です。公式のDiscussionで作者は読み方にこだわらないと述べており、英語風の「ズースタンド」でも構いません。
Zustandの最新バージョンはいくつ?
2026年10月31日時点の最新は2026年10月22日公開の5.0.16です。手元の版は npm ls zustand で確認できます。
複数のストアを作ってもよい?
作って構いません。互いに参照しない状態なら用途別に別のストアに分け、互いのアクションから相手の状態を読むならSliceパターンで1つのストアにまとめると型と処理を共有できます。
useShallowはいつ必要?
セレクタがオブジェクトや配列など新しい参照を返すときです。(state) => state.count のように1つの値を返すセレクタには不要です。
Next.jsのServer Componentsで使える?
使えません。ストアを読むコンポーネントには 'use client' を付け、Server Componentsで取得した値はProviderの初期値として渡します。