「Zustand 使い方を完璧にマスターしたい」「Reduxは冗長すぎる…でもuseContextでは再レンダリングが辛い」――そんな2026年の現役Reactエンジニアに最適解を提示するのが本記事です。Zustandはわずか1.2KB(gzip)というBundle Sizeながら、Reduxレベルの強力な状態管理を create() 一発で実現する人気No.1ライブラリ。本ガイドでは Zustand TypeScript 型推論を完備した最小構成から、selectorパターン・shallow比較・persist(localStorage永続化)・immer(不変性)・devtools・subscribeWithSelector・combine・Slices Pattern(store分割)・Async Actions・Optimistic Update・Computed State・Next.js 15 App Router対応・テスト戦略まで、40本超のコピペで動くコードとBefore/Afterリファクタリングを中心に徹底解説します。読み終えるころには、Reduxからの移行判断・store設計・パフォーマンス最適化を自走で進められるレベルになります。
- Zustandとは何か――1.2KBで完結する「最も人気のある」React状態管理ライブラリ
- セットアップ:インストールから最小構成のstoreまで
- TypeScript型推論を完璧にする3つのパターン
- Selectorパターン:パフォーマンスを劇的に改善する核心テクニック
- persist middleware:localStorage永続化を3行で実現
- immer middleware:ネストしたstateの不変更新を直感的に書く
- devtools middleware:Redux DevToolsで状態遷移を可視化
- Slices Pattern:大規模storeを綺麗に分割する
- combine middleware:型推論をさらに楽にする糖衣構文
- subscribeWithSelector:storeの変化を外部から監視する
- Async Actions:非同期処理をstoreの中で完結させる
- Computed State:derived value(派生値)を綺麗に扱う
- Next.js 15 App Router対応:SSR・hydration安全な使い方
- Reduxからの移行:Before/After完全比較
- テスト戦略:Zustand storeをVitest/Jestで検証する
- よくあるFAQ・トラブルシューティング
- 本記事のまとめ:Zustandで何が変わるか
Zustandとは何か――1.2KBで完結する「最も人気のある」React状態管理ライブラリ
Zustand(ドイツ語で「状態」)は、React Three Fiberの作者pmndrs(Poimandres)が開発した状態管理ライブラリです。2026年現在、npm週間ダウンロード数は500万超、State of React 2025の満足度ランキングでも1位を獲得し、Redux Toolkitに次ぐ第2勢力として確立しています。後発のため Redux の課題(boilerplate地獄・Provider地獄・selector地獄)を全て学習した上で設計されており、「Reduxの強さ + useStateの手軽さ」を両立しているのが最大の特徴です。
Zustandが選ばれる5つの理由
「なぜ今 Zustand 使い方 を学ぶべきか?」――2026年時点でZustandを選ぶ実利的な根拠は以下の通りです。
- Bundle Size 1.2KB:Reduxの1/10以下。production buildへの影響が無視できるレベル
- Provider不要:アプリのrootを
<Provider>で囲む必要がない。SSRも安全 - Boilerplate最小:action types/reducers/dispatchの分離が不要
- TypeScript完全対応:型推論が強力で、anyを書く場面がほぼゼロ
- middlewareエコシステム:persist・immer・devtools・subscribeWithSelector が公式提供
| 項目 | Zustand | Redux Toolkit | Jotai | useContext |
|---|---|---|---|---|
| Bundle Size (gzip) | 1.2KB | 11.5KB | 3.4KB | 0KB |
| Provider必須 | 不要 | 必須 | 推奨 | 必須 |
| Boilerplate量 | 最小 | 多い | 少ない | 少ない |
| DevTools対応 | ○ (middleware) | ◎ | ○ | × |
| SSR対応 | ◎ | ○ | ○ | ◎ |
| 学習コスト | 低 | 中〜高 | 中 | 低 |
useContextやReduxとの根本的な違い
状態管理の選定で迷ったら、React状態管理ライブラリ完全比較〜Redux Toolkit・Zustand・Jotai・Valtio・TanStack Query〜【2026年版】でライブラリ全体の俯瞰図を確認してください。本記事は 「Zustandを採用する前提で、どう実装するか」 に完全特化しています。useContextとの違いはuseContext完全ガイドもあわせて参照すると理解が深まります。
セットアップ:インストールから最小構成のstoreまで
Zustand導入の前提環境を表にまとめます。React 18以上を推奨しますが、React 16.8以降(Hooks対応版)であれば動作します。
| 項目 | 推奨 | 最低 | 備考 |
|---|---|---|---|
| React | 18.x / 19.x | 16.8 | Hooks必須 |
| TypeScript | 5.x | 4.5 | satisfies演算子を活用 |
| Node.js | 20.x LTS | 18.x | ESM完全対応版 |
| Zustand本体 | 5.x | 4.5 | 5.xでuseShallow導入 |
| bundler | Vite / Next.js 15 | Webpack 5 | tree shaking前提 |
npm/pnpm/yarn でインストール
まずは依存追加です。React 18以上(React 19 / 16.8以上でも動作)であればOK。Next.js 15 App Routerでも追加設定は不要です。
# npm
npm install zustand
# pnpm
pnpm add zustand
# yarn
yarn add zustand
# bun
bun add zustand
最小構成: カウンターstoreを5行で書く
Zustandの最小コードは驚くほどシンプル。create()に「現在のstateを返す関数」を渡すだけで、Reactで使えるhookが生成されます。
// stores/counter.ts
import { create } from "zustand";
type CounterState = {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
};
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
コンポーネント側はhookを呼び出すだけで、Providerは一切不要です。
// components/Counter.tsx
"use client";
import { useCounterStore } from "@/stores/counter";
export function Counter() {
const count = useCounterStore((s) => s.count);
const increment = useCounterStore((s) => s.increment);
const decrement = useCounterStore((s) => s.decrement);
const reset = useCounterStore((s) => s.reset);
return (
<div>
<p>count: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>reset</button>
</div>
);
}
setとgetの使い分け
create()の引数は (set, get, store) => ({...}) の形を取ります。setはstate更新、getは現在のstate参照、storeはstore自身のAPI(subscribe/getStateなど)です。
import { create } from "zustand";
type TodoState = {
todos: string[];
addTodo: (text: string) => void;
removeTodo: (index: number) => void;
count: () => number; // computed (get経由)
};
export const useTodoStore = create<TodoState>((set, get) => ({
todos: [],
addTodo: (text) => set((s) => ({ todos: [...s.todos, text] })),
removeTodo: (index) =>
set((s) => ({ todos: s.todos.filter((_, i) => i !== index) })),
count: () => get().todos.length, // 関数として呼び出す
}));
TypeScript型推論を完璧にする3つのパターン
Zustand TypeScript 連携は公式ドキュメントで詳述されており、型推論の質はReduxよりも高いと評価されています。ここでは現場で頻出する3パターンを解説します。
パターン1: 基本のcreate<T>()
最も一般的な書き方。create<Type>() とジェネリクスを渡すことで、setの引数・返り値・useStoreの戻り値まで型が伝播します。
type UserState = {
name: string;
age: number;
setName: (name: string) => void;
setAge: (age: number) => void;
};
export const useUserStore = create<UserState>((set) => ({
name: "",
age: 0,
setName: (name) => set({ name }), // name は string に推論される
setAge: (age) => set({ age }),
}));
パターン2: create()()のカリー化記法(middleware利用時必須)
middlewareをチェーンすると型推論が壊れることがあります。その場合は「create()(…)」のカリー化記法を使うと型が完全に推論されます。
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
type Settings = {
theme: "light" | "dark";
setTheme: (t: "light" | "dark") => void;
};
// 注: create<T>() の後にもう一度 () をつける
export const useSettingsStore = create<Settings>()(
devtools(
persist(
(set) => ({
theme: "light",
setTheme: (theme) => set({ theme }),
}),
{ name: "settings-storage" }
)
)
);
パターン3: StateCreator型で関数を切り出す
storeのロジックが肥大化したら、StateCreator型でslicesに切り出すのが定石です。
import { create, StateCreator } from "zustand";
type AuthSlice = {
user: { id: string; name: string } | null;
login: (id: string, name: string) => void;
logout: () => void;
};
const createAuthSlice: StateCreator<AuthSlice> = (set) => ({
user: null,
login: (id, name) => set({ user: { id, name } }),
logout: () => set({ user: null }),
});
export const useAuthStore = create<AuthSlice>(createAuthSlice);
Selectorパターン:パフォーマンスを劇的に改善する核心テクニック
Zustandの真価はSelectorパターンにあります。store全体ではなく「必要な値だけ」をsubscribeすることで、不要な再レンダリングを完全に排除できます。Selector運用で押さえるべき原則は以下の通りです。
- 1値1selector:
(s) => s.countのように単一値を取り出すのが最速 - object/arrayはuseShallow:複数値をまとめる場合は必ず浅い比較を挟む
- actionsはまとめ取りOK:関数の参照は不変なのでuseShallowで一括取得して問題なし
- 派生値はselector内で計算:storeに保存せず、selectorで都度導出する
- カスタムhook化:selectorをcomponent内に直書きせず、
useCount等にラップする
Before:store全体を購読する悪い例
// 🚫 アンチパターン:store全体を返すとどの値が変わっても再render
function BadCounter() {
const store = useCounterStore(); // 全部購読
return <p>{store.count}</p>;
}
After:必要な値だけselectorで取り出す
// ✅ 推奨:countが変わった時だけ再render
function GoodCounter() {
const count = useCounterStore((s) => s.count);
return <p>{count}</p>;
}
// ✅ actionだけ取り出すパターン(actionの参照は不変)
function CounterButtons() {
const increment = useCounterStore((s) => s.increment);
const decrement = useCounterStore((s) => s.decrement);
return (
<>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</>
);
}
複数値をまとめて取り出したい時:useShallowを使う
「複数の値を一度に取り出したい」場合、object/arrayをそのまま返すと参照が毎回変わって再renderが発生します。Zustand 4.4以降はuseShallow(以前のshallow比較APIの後継)を使います。
import { useShallow } from "zustand/react/shallow";
// 🚫 NG: 毎回新しいobjectが作られ、毎回再render
function BadProfile() {
const { name, age } = useUserStore((s) => ({ name: s.name, age: s.age }));
return <p>{name} ({age})</p>;
}
// ✅ OK: useShallowで浅い比較
function GoodProfile() {
const { name, age } = useUserStore(
useShallow((s) => ({ name: s.name, age: s.age }))
);
return <p>{name} ({age})</p>;
}
// ✅ OK: 配列でもuseShallow
function PickList() {
const [a, b] = useUserStore(useShallow((s) => [s.name, s.age]));
return <p>{a} / {b}</p>;
}
selectorの分離パターン:カスタムhookに切り出す
selectorを直接書くとコンポーネントが汚れます。カスタムhookに切り出すのがチーム開発のベストプラクティスです。
// stores/counter.ts に selectors を集約
import { useCounterStore } from "./counter";
export const useCount = () => useCounterStore((s) => s.count);
export const useCounterActions = () =>
useCounterStore(
useShallow((s) => ({
increment: s.increment,
decrement: s.decrement,
reset: s.reset,
}))
);
// 利用側はクリーン
function Counter() {
const count = useCount();
const { increment, decrement, reset } = useCounterActions();
return (...);
}
persist middleware:localStorage永続化を3行で実現
ユーザー設定・ログイン状態・テーマなど、リロードしても保持したい値はpersist middlewareを使います。デフォルトでlocalStorageに保存され、起動時に自動復元されます。persistを採用する際の判断基準は以下を参考にしてください。
- 永続化すべき例:テーマ設定・UIサイドバー開閉・最近見た商品・チュートリアル完了フラグ
- 永続化を避けるべき例:認証トークン(HTTPOnly Cookie推奨)・パスワード・PII・サーバー由来の重いキャッシュ
- partializeで限定:storeの一部だけ保存することでXSS時の被害を最小化できる
- versionで安全に進化:schema変更時はmigrate関数で既存ユーザーのlocalStorageを破壊しない
基本のpersist構成
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
type ThemeStore = {
theme: "light" | "dark";
toggle: () => void;
};
export const useThemeStore = create<ThemeStore>()(
persist(
(set) => ({
theme: "light",
toggle: () =>
set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
}),
{
name: "theme-storage", // localStorage key
storage: createJSONStorage(() => localStorage),
}
)
);
一部の値だけ永続化する:partialize
storeの一部だけ保存したいケース(機密情報は除外したいなど)ではpartializeを使います。
type AppState = {
user: { id: string; token: string } | null;
ui: { sidebarOpen: boolean };
setUser: (u: AppState["user"]) => void;
toggleSidebar: () => void;
};
export const useAppStore = create<AppState>()(
persist(
(set) => ({
user: null,
ui: { sidebarOpen: true },
setUser: (user) => set({ user }),
toggleSidebar: () =>
set((s) => ({ ui: { sidebarOpen: !s.ui.sidebarOpen } })),
}),
{
name: "app-storage",
// tokenは保存しない、UI状態のみ永続化
partialize: (state) => ({ ui: state.ui }),
}
)
);
sessionStorage / IndexedDB / Cookieに変更する
localStorage以外のstorageに切り替えるのも1行です。
import { persist, createJSONStorage } from "zustand/middleware";
// sessionStorage
storage: createJSONStorage(() => sessionStorage),
// 自前のstorage(例: Cookie / IndexedDB)
storage: createJSONStorage(() => ({
getItem: async (name) => await myDB.get(name),
setItem: async (name, value) => await myDB.set(name, value),
removeItem: async (name) => await myDB.delete(name),
})),
schemaが変わった時のmigration
アプリのアップデートでstoreの構造が変わったら、versionとmigrateを使うとユーザーのlocalStorageを破壊せず移行できます。
persist(
(set) => ({ /* ... */ }),
{
name: "user-storage",
version: 2, // 旧データを検知
migrate: (persistedState, version) => {
if (version === 0) {
// v0 -> v2: usernameをfirstName/lastNameに分解
const old = persistedState as { username: string };
const [firstName = "", lastName = ""] = old.username.split(" ");
return { firstName, lastName } as any;
}
return persistedState as any;
},
}
)
immer middleware:ネストしたstateの不変更新を直感的に書く
state内に深くネストしたobject/arrayがあるとsetの中で...spread地獄になりがちです。immer middlewareを使えば「mutableに書いてimmutableに更新される」魔法が手に入ります。
Before:spread地獄
// 🚫 ネストが深いと辛い
type State = {
users: { id: string; profile: { name: string; age: number } }[];
};
const update = (id: string, newName: string) =>
set((s: State) => ({
users: s.users.map((u) =>
u.id === id
? { ...u, profile: { ...u.profile, name: newName } }
: u
),
}));
After:immerでmutable記法
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
type State = {
users: { id: string; profile: { name: string; age: number } }[];
updateName: (id: string, name: string) => void;
};
export const useUsersStore = create<State>()(
immer((set) => ({
users: [],
updateName: (id, name) =>
set((draft) => {
const user = draft.users.find((u) => u.id === id);
if (user) user.profile.name = name; // 直接代入でOK
}),
}))
);
immer版は「直接代入」「pushでappend」「spliceで削除」のように、JavaScript本来の書き味でstateを更新できます。内部的にはimmerが構造共有(structural sharing)を行い、変更箇所のみ新しい参照を作るため、Zustandの再render判定も正しく動きます。
immer + persist のチェーン
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
export const useStore = create<State>()(
persist(
immer((set) => ({
users: [],
addUser: (u) =>
set((draft) => {
draft.users.push(u);
}),
})),
{ name: "users-storage" }
)
);
devtools middleware:Redux DevToolsで状態遷移を可視化
「いつ・どこで・誰がstateを変えたか」を可視化したい場合、devtools middlewareがベストです。Redux DevTools拡張機能(Chrome/Firefox)にそのまま統合されます。
import { create } from "zustand";
import { devtools } from "zustand/middleware";
export const useCounterStore = create<CounterState>()(
devtools(
(set) => ({
count: 0,
increment: () =>
set(
(s) => ({ count: s.count + 1 }),
undefined,
"counter/increment" // action名
),
decrement: () =>
set(
(s) => ({ count: s.count - 1 }),
undefined,
"counter/decrement"
),
}),
{ name: "CounterStore", enabled: process.env.NODE_ENV !== "production" }
)
);
第3引数の"counter/increment"のようなaction名を付けると、Redux DevToolsのlogに見やすく表示されます。production環境ではenabled: falseでビルドサイズを節約しましょう。
Slices Pattern:大規模storeを綺麗に分割する
storeが大きくなったら、ドメインごとにslices(スライス)に分割するのがベストプラクティスです。1ファイル500行の巨大storeを、4〜5ファイルの200行ずつに分けます。
Step 1: slice型を定義
// stores/slices/types.ts
import { StateCreator } from "zustand";
export type UserSlice = {
user: { id: string; name: string } | null;
setUser: (user: UserSlice["user"]) => void;
};
export type CartSlice = {
items: { productId: string; quantity: number }[];
addItem: (productId: string) => void;
removeItem: (productId: string) => void;
};
export type UISlice = {
isSidebarOpen: boolean;
toggleSidebar: () => void;
};
// 全sliceを合成した型
export type AppStore = UserSlice & CartSlice & UISlice;
// 各sliceの作成関数の型(他slice参照可)
export type SliceCreator<T> = StateCreator<
AppStore,
[],
[],
T
>;
Step 2: 各sliceを実装
// stores/slices/userSlice.ts
import { SliceCreator, UserSlice } from "./types";
export const createUserSlice: SliceCreator<UserSlice> = (set) => ({
user: null,
setUser: (user) => set({ user }),
});
// stores/slices/cartSlice.ts
import { SliceCreator, CartSlice } from "./types";
export const createCartSlice: SliceCreator<CartSlice> = (set, get) => ({
items: [],
addItem: (productId) => {
const items = get().items;
const existing = items.find((i) => i.productId === productId);
if (existing) {
set({
items: items.map((i) =>
i.productId === productId
? { ...i, quantity: i.quantity + 1 }
: i
),
});
} else {
set({ items: [...items, { productId, quantity: 1 }] });
}
},
removeItem: (productId) =>
set((s) => ({
items: s.items.filter((i) => i.productId !== productId),
})),
});
// stores/slices/uiSlice.ts
import { SliceCreator, UISlice } from "./types";
export const createUISlice: SliceCreator<UISlice> = (set) => ({
isSidebarOpen: true,
toggleSidebar: () =>
set((s) => ({ isSidebarOpen: !s.isSidebarOpen })),
});
Step 3: storeに合成する
// stores/useAppStore.ts
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { AppStore } from "./slices/types";
import { createUserSlice } from "./slices/userSlice";
import { createCartSlice } from "./slices/cartSlice";
import { createUISlice } from "./slices/uiSlice";
export const useAppStore = create<AppStore>()(
devtools(
persist(
(...a) => ({
...createUserSlice(...a),
...createCartSlice(...a),
...createUISlice(...a),
}),
{
name: "app-storage",
partialize: (s) => ({ user: s.user, items: s.items }),
}
),
{ name: "AppStore" }
)
);
combine middleware:型推論をさらに楽にする糖衣構文
初期state(values)とactionsを分けて書きたい場合、combineを使うと型を二重に書く必要がなくなります。
import { create } from "zustand";
import { combine } from "zustand/middleware";
// 型定義を書かなくても完全推論される
export const useCounterStore = create(
combine(
{ count: 0, lastUpdated: 0 }, // 初期state
(set) => ({
increment: () =>
set((s) => ({ count: s.count + 1, lastUpdated: Date.now() })),
decrement: () =>
set((s) => ({ count: s.count - 1, lastUpdated: Date.now() })),
})
)
);
// 使用側:countもincrementも完全に型推論される
const count = useCounterStore((s) => s.count);
subscribeWithSelector:storeの変化を外部から監視する
「storeが変わったら他システムに通知したい」(ログ送信・WebSocket送信・analyticsイベントなど)場合はsubscribeWithSelectorを使います。
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
export const useCartStore = create<CartState>()(
subscribeWithSelector((set) => ({
items: [],
addItem: (item) =>
set((s) => ({ items: [...s.items, item] })),
}))
);
// アプリ起動時に1度だけセットアップ
useCartStore.subscribe(
(state) => state.items.length, // selector
(newCount, prevCount) => {
console.log(`cart changed: ${prevCount} -> ${newCount}`);
// analytics.track("cart_updated", { count: newCount });
},
{
equalityFn: (a, b) => a === b, // optional
fireImmediately: false,
}
);
Transient updates:再renderせずに値だけ参照する
「アニメーションのフレーム毎に最新値を読みたい」のように、re-renderを発生させずにstoreを読みたい場合はsubscribe + refパターンを使います。
import { useEffect, useRef } from "react";
function CanvasComponent() {
const ref = useRef(useCounterStore.getState().count);
useEffect(() => {
// 再renderを発生させずにrefだけ更新
const unsub = useCounterStore.subscribe((s) => {
ref.current = s.count;
});
return unsub;
}, []);
// requestAnimationFrame内などで ref.current を読み放題
return <canvas />;
}
Async Actions:非同期処理をstoreの中で完結させる
API呼び出し・データfetch・認証などの非同期処理も、Zustandのactionにそのまま書けます。Reduxのthunk/sagaのような追加middlewareは不要です。
基本のasync action
type UserState = {
user: { id: string; name: string } | null;
loading: boolean;
error: string | null;
fetchUser: (id: string) => Promise<void>;
};
export 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}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const user = await res.json();
set({ user, loading: false });
} catch (e) {
set({ error: (e as Error).message, loading: false });
}
},
}));
Optimistic Update:楽観的更新パターン
「いいね」「カート追加」など、UXのためにAPI応答を待たずに即座にUIを更新するパターンです。失敗したらロールバックします。
type LikeState = {
likes: Record<string, boolean>;
toggleLike: (postId: string) => Promise<void>;
};
export const useLikeStore = create<LikeState>()((set, get) => ({
likes: {},
toggleLike: async (postId) => {
const prev = get().likes[postId] ?? false;
// 楽観的に即座にUI更新
set((s) => ({ likes: { ...s.likes, [postId]: !prev } }));
try {
const res = await fetch(`/api/posts/${postId}/like`, {
method: "POST",
});
if (!res.ok) throw new Error();
} catch {
// 失敗したらロールバック
set((s) => ({ likes: { ...s.likes, [postId]: prev } }));
}
},
}));
Computed State:derived value(派生値)を綺麗に扱う
「カートの合計金額」「ログイン済みかどうか」のような派生値は、storeに直接持たず、selectorで導出するのが鉄則です。
Selector内で計算する基本パターン
type CartState = {
items: { id: string; price: number; qty: number }[];
};
export const useCartStore = create<CartState>()(() => ({
items: [],
}));
// 派生値はselectorで:storeに保存しない
export const useCartTotal = () =>
useCartStore((s) =>
s.items.reduce((acc, item) => acc + item.price * item.qty, 0)
);
export const useCartItemCount = () =>
useCartStore((s) => s.items.length);
重い計算は外でmemo化する
import { useMemo } from "react";
function CartSummary() {
const items = useCartStore((s) => s.items);
// 重い計算はuseMemoで囲む
const total = useMemo(
() => items.reduce((acc, i) => acc + i.price * i.qty, 0),
[items]
);
return <p>合計: ¥{total.toLocaleString()}</p>;
}
Next.js 15 App Router対応:SSR・hydration安全な使い方
Next.js 15(App Router/RSC)でZustandを使うときは、「グローバルstoreをServer Componentで触らない」ことが鉄則です。Server Componentは毎リクエストで実行されるため、グローバルstoreを共有するとユーザー間でstateが混ざります。
解決策:RequestごとにstoreをProvider経由で生成
// stores/userStore.ts
import { createStore } from "zustand/vanilla";
export type UserState = {
user: { id: string; name: string } | null;
setUser: (u: UserState["user"]) => void;
};
export const createUserStore = (initState?: Partial<UserState>) =>
createStore<UserState>()((set) => ({
user: null,
...initState,
setUser: (user) => set({ user }),
}));
// providers/UserStoreProvider.tsx
"use client";
import { createContext, useContext, useRef } from "react";
import { useStore } from "zustand";
import { createUserStore, UserState } from "@/stores/userStore";
type UserStoreApi = ReturnType<typeof createUserStore>;
const UserStoreContext = createContext<UserStoreApi | null>(null);
export function UserStoreProvider({
children,
initState,
}: {
children: React.ReactNode;
initState?: Partial<UserState>;
}) {
const storeRef = useRef<UserStoreApi>();
if (!storeRef.current) {
storeRef.current = createUserStore(initState);
}
return (
<UserStoreContext.Provider value={storeRef.current}>
{children}
</UserStoreContext.Provider>
);
}
export function useUserStore<T>(selector: (s: UserState) => T): T {
const ctx = useContext(UserStoreContext);
if (!ctx) throw new Error("UserStoreProvider missing");
return useStore(ctx, selector);
}
// app/layout.tsx
import { UserStoreProvider } from "@/providers/UserStoreProvider";
export default async function RootLayout({ children }) {
// Server Componentで初期データ取得
const user = await fetchUserFromCookies();
return (
<html>
<body>
<UserStoreProvider initState={{ user }}>
{children}
</UserStoreProvider>
</body>
</html>
);
}
persistとhydration mismatch対策
localStorageの値はクライアントでしか読めません。SSRと初回renderでstate差分が出るとHydration mismatchエラーになります。
// persistのhydration完了を待つhook
import { useEffect, useState } from "react";
export function useHydrated<T>(store: any) {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(store.persist.hasHydrated());
const unsub = store.persist.onFinishHydration(() => setHydrated(true));
return unsub;
}, [store]);
return hydrated;
}
// 利用例
function App() {
const hydrated = useHydrated(useThemeStore);
if (!hydrated) return <Skeleton />;
return <ThemedUI />;
}
Reduxからの移行:Before/After完全比較
「Redux Toolkitで書いてある既存プロジェクトをZustandに移行したい」――実は移行はそれほど大変ではありません。1 slice = 1 store の対応関係で機械的に書き換えられます。
Before:Redux Toolkit版
// store/counterSlice.ts (Redux Toolkit)
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type CounterState = { value: number };
const initialState: CounterState = { value: 0 };
export const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
incrementBy: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
},
});
export const { increment, incrementBy } = counterSlice.actions;
// store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import { counterSlice } from "./counterSlice";
export const store = configureStore({
reducer: { counter: counterSlice.reducer },
});
// 利用側
import { useSelector, useDispatch } from "react-redux";
const value = useSelector((s: RootState) => s.counter.value);
const dispatch = useDispatch();
dispatch(increment());
dispatch(incrementBy(5));
After:Zustand版(行数1/3に圧縮)
// stores/counter.ts (Zustand)
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
type CounterState = {
value: number;
increment: () => void;
incrementBy: (n: number) => void;
};
export const useCounterStore = create<CounterState>()(
immer((set) => ({
value: 0,
increment: () => set((s) => { s.value += 1; }),
incrementBy: (n) => set((s) => { s.value += n; }),
}))
);
// 利用側(Providerすら不要)
const value = useCounterStore((s) => s.value);
const increment = useCounterStore((s) => s.increment);
const incrementBy = useCounterStore((s) => s.incrementBy);
increment();
incrementBy(5);
| 項目 | Redux Toolkit | Zustand |
|---|---|---|
| 初期セットアップ | configureStore + Provider | create() のみ |
| action定義 | createSlice の reducers | store内に直接書く |
| dispatch | useDispatch + actionCreator | actionを直接呼ぶ |
| selector | useSelector + RootState型 | useStore((s) => …) |
| 不変性 | immer内蔵 | immer middleware選択式 |
| 非同期 | createAsyncThunk | asyncをそのままaction化 |
テスト戦略:Zustand storeをVitest/Jestで検証する
Zustandの大きな利点はテストの書きやすさです。storeは純粋な関数で構成されているため、Reactなしでロジックだけテストできます。
Pure Storeとしてテスト
// stores/counter.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { useCounterStore } from "./counter";
describe("counter store", () => {
beforeEach(() => {
// 毎回initial stateにリセット
useCounterStore.setState({ count: 0 });
});
it("incrementで1増える", () => {
useCounterStore.getState().increment();
expect(useCounterStore.getState().count).toBe(1);
});
it("resetで0に戻る", () => {
useCounterStore.setState({ count: 99 });
useCounterStore.getState().reset();
expect(useCounterStore.getState().count).toBe(0);
});
});
storeリセット用のhelperを用意する
テスト間でstateを完全に初期化したい場合は、initialStateを定数化しておくのがおすすめです。
// stores/counter.ts
const initialState = { count: 0 };
export const useCounterStore = create<CounterState>((set) => ({
...initialState,
increment: () => set((s) => ({ count: s.count + 1 })),
reset: () => set(initialState),
}));
// テストのbeforeEachで利用
beforeEach(() => {
useCounterStore.setState({ ...initialState, ...actions });
});
React Testing Libraryでcomponentテスト
import { render, screen, fireEvent } from "@testing-library/react";
import { Counter } from "./Counter";
import { useCounterStore } from "@/stores/counter";
beforeEach(() => useCounterStore.setState({ count: 0 }));
it("ボタンクリックでcountが増える", () => {
render(<Counter />);
fireEvent.click(screen.getByText("+1"));
expect(screen.getByText("count: 1")).toBeInTheDocument();
});
よくあるFAQ・トラブルシューティング
Q1. selectorを使わずstore全体を取り出してしまった、何が起きる?
store全体を返すとどの値が変わっても全componentが再renderされます。countだけ使っているcomponentでも、name更新でre-renderされてしまいます。必ず(s) => s.countのようにpinpointで取り出してください。
Q2. オブジェクトをselectorで返すと無限ループになる
毎回新しいobject参照が作られて、内部の比較で「変更あり」と判定されるためです。useShallowを使うか、individual selectorに分解してください。
// 🚫
const { a, b } = useStore((s) => ({ a: s.a, b: s.b }));
// ✅ useShallowで解決
import { useShallow } from "zustand/react/shallow";
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
// ✅ 個別取得でも解決
const a = useStore((s) => s.a);
const b = useStore((s) => s.b);
Q3. Next.js App RouterでHydration mismatchが出る
persist middlewareでlocalStorageに依存するstateを表示する場合、初回renderはサーバー値、その後localStorage値で更新されてmismatchします。useHydratedhookで「persist復元完了まで描画を遅らせる」のが最も簡単です。
Q4. Zustandは複数のstoreを作っても大丈夫?
はい、推奨されています。Reduxのように「アプリ全体で1 store」ではなく、ドメインごとに小さなstoreを複数作るのがZustand流のベストプラクティスです。useUserStore、useCartStore、useUIStoreのように分けましょう。
Q5. Reduxからの移行はどう進めるべき?
一度に全部書き換える必要はありません。Reduxとは独立にZustand storeを追加でき、新規機能からZustandを採用→既存slice単位で順次置換、という段階的移行が可能です。
Q6. Server Componentsからstoreを読みたい場合は?
Server Componentsからglobal storeを直接触ることは推奨されません。RequestごとにstoreをProvider経由で生成(本記事のNext.js章を参照)し、サーバー側はfetchで取得、clientでstoreに渡す形にしてください。
Q7. ZustandとTanStack Queryは併用していい?
はい、むしろ推奨される組み合わせです。サーバー状態(API取得データ)はTanStack Query、クライアント状態(UI状態・認証・設定)はZustandと役割分担するのが2026年の主流構成です。
本記事のまとめ:Zustandで何が変わるか
本記事で扱ったZustand 使い方のポイントを6点にまとめます。Zustandを採用すると、Reduxで書いていた1000行が300行に圧縮され、provider地獄が解消し、TypeScript型推論が完璧に効きます。最小構成は5行、middlewareを足しても可読性は保たれ、テストは純粋関数として実行できる――これがZustandが「最も人気のあるReact状態管理ライブラリ」になった理由です。
- 最小構成は5行:
create<T>()でhookが生成され、Provider不要で即動く - Selectorパターン必須:不要な再renderを完全に排除、複数値は
useShallow - persist/immer/devtools/combine/subscribeWithSelector:中規模以上で必須の5大middleware
- Slices Pattern:大規模storeはドメインごとに分割。
StateCreator型で安全に合成 - Next.js 15対応:Server Componentで触らず、Provider経由でリクエスト単位にstore生成
- テスト容易性:storeはpure functionなので Vitest/Jest で軽快にテストできる
Zustandを今すぐ採用するか迷っている方は、まずは新規featureから試してみてください。最小構成5行から始め、必要に応じてmiddlewareを足していくだけで、Reduxレベルの強い状態管理が手に入ります。さらに状態管理全般の選び方を整理したい場合は、React状態管理ライブラリ完全比較〜Redux Toolkit・Zustand・Jotai・Valtio・TanStack Query〜【2026年版】とuseContext完全ガイド〜Context API・状態管理・パフォーマンス〜【2026年版】もあわせて読むと、選定の解像度が一段上がります。

コメント