Zustand完全実践ガイド〜最小構成・middleware・永続化・TypeScript型推論〜【2026年版】

Zustand 使い方を完璧にマスターしたい」「Reduxは冗長すぎる…でもuseContextでは再レンダリングが辛い」――そんな2026年の現役Reactエンジニアに最適解を提示するのが本記事です。Zustandはわずか1.2KB(gzip)というBundle Sizeながら、Reduxレベルの強力な状態管理create() 一発で実現する人気No.1ライブラリ。本ガイドでは Zustand TypeScript 型推論を完備した最小構成から、selectorパターン・shallow比較・persist(localStorage永続化)・immer(不変性)・devtoolssubscribeWithSelectorcombineSlices Pattern(store分割)・Async Actions・Optimistic Update・Computed State・Next.js 15 App Router対応・テスト戦略まで、40本超のコピペで動くコードBefore/Afterリファクタリングを中心に徹底解説します。読み終えるころには、Reduxからの移行判断・store設計・パフォーマンス最適化を自走で進められるレベルになります。

  1. Zustandとは何か――1.2KBで完結する「最も人気のある」React状態管理ライブラリ
    1. Zustandが選ばれる5つの理由
    2. useContextやReduxとの根本的な違い
  2. セットアップ:インストールから最小構成のstoreまで
    1. npm/pnpm/yarn でインストール
    2. 最小構成: カウンターstoreを5行で書く
    3. setとgetの使い分け
  3. TypeScript型推論を完璧にする3つのパターン
    1. パターン1: 基本のcreate<T>()
    2. パターン2: create()()のカリー化記法(middleware利用時必須)
    3. パターン3: StateCreator型で関数を切り出す
  4. Selectorパターン:パフォーマンスを劇的に改善する核心テクニック
    1. Before:store全体を購読する悪い例
    2. After:必要な値だけselectorで取り出す
    3. 複数値をまとめて取り出したい時:useShallowを使う
    4. selectorの分離パターン:カスタムhookに切り出す
  5. persist middleware:localStorage永続化を3行で実現
    1. 基本のpersist構成
    2. 一部の値だけ永続化する:partialize
    3. sessionStorage / IndexedDB / Cookieに変更する
    4. schemaが変わった時のmigration
  6. immer middleware:ネストしたstateの不変更新を直感的に書く
    1. Before:spread地獄
    2. After:immerでmutable記法
    3. immer + persist のチェーン
  7. devtools middleware:Redux DevToolsで状態遷移を可視化
  8. Slices Pattern:大規模storeを綺麗に分割する
    1. Step 1: slice型を定義
    2. Step 2: 各sliceを実装
    3. Step 3: storeに合成する
  9. combine middleware:型推論をさらに楽にする糖衣構文
  10. subscribeWithSelector:storeの変化を外部から監視する
    1. Transient updates:再renderせずに値だけ参照する
  11. Async Actions:非同期処理をstoreの中で完結させる
    1. 基本のasync action
    2. Optimistic Update:楽観的更新パターン
  12. Computed State:derived value(派生値)を綺麗に扱う
    1. Selector内で計算する基本パターン
    2. 重い計算は外でmemo化する
  13. Next.js 15 App Router対応:SSR・hydration安全な使い方
    1. 解決策:RequestごとにstoreをProvider経由で生成
    2. persistとhydration mismatch対策
  14. Reduxからの移行:Before/After完全比較
    1. Before:Redux Toolkit版
    2. After:Zustand版(行数1/3に圧縮)
  15. テスト戦略:Zustand storeをVitest/Jestで検証する
    1. Pure Storeとしてテスト
    2. storeリセット用のhelperを用意する
    3. React Testing Libraryでcomponentテスト
  16. よくあるFAQ・トラブルシューティング
    1. Q1. selectorを使わずstore全体を取り出してしまった、何が起きる?
    2. Q2. オブジェクトをselectorで返すと無限ループになる
    3. Q3. Next.js App RouterでHydration mismatchが出る
    4. Q4. Zustandは複数のstoreを作っても大丈夫?
    5. Q5. Reduxからの移行はどう進めるべき?
    6. Q6. Server Componentsからstoreを読みたい場合は?
    7. Q7. ZustandとTanStack Queryは併用していい?
  17. 本記事のまとめ: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. 1値1selector:(s) => s.count のように単一値を取り出すのが最速
  2. object/arrayはuseShallow:複数値をまとめる場合は必ず浅い比較を挟む
  3. actionsはまとめ取りOK:関数の参照は不変なのでuseShallowで一括取得して問題なし
  4. 派生値はselector内で計算:storeに保存せず、selectorで都度導出する
  5. カスタム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の構造が変わったら、versionmigrateを使うとユーザーの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流のベストプラクティスです。useUserStoreuseCartStoreuseUIStoreのように分けましょう。

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年版】もあわせて読むと、選定の解像度が一段上がります。

コメント

タイトルとURLをコピーしました