【TypeScript × Zustand】型安全な状態管理完全ガイド|ストア設計・非同期処理・Immer・DevTools・テストまで徹底解説

【TypeScript × Zustand】型安全な状態管理完全ガイド|ストア設計・非同期処理・Immer・DevTools・テストまで徹底解説 TypeScript

Reactの状態管理ライブラリといえばReduxが有名ですが、2020年以降はZustand(ツーシュタント)が急速に普及しています。GitHubスター数は2024年時点で44,000超、週間ダウンロード数は700万を超えるほど成長しました。

Zustandが人気の理由の一つがTypeScriptとの高い親和性です。複雑なセットアップなしに型推論が効き、ストアの型を一箇所に定義するだけで型安全なアクセスができます。本記事ではZustandをTypeScriptで使い倒すための知識を体系的に解説します。

この記事でわかること

  • ZustandとRedux Toolkitの違い・選び方
  • 型安全なストアの定義(state・actions をまとめた interface 設計)
  • 非同期アクション(APIコール)の型定義パターン
  • Immerミドルウェアを使ったイミュータブル更新の簡略化
  • DevTools・persist・subscribeWithSelectorミドルウェアの使い方
  • スライスパターンによる大規模ストア設計
  • セレクターを使った再レンダリング最適化
  • VitestでZustandストアをテストする方法

Reactの型定義全般についてはTypeScript × React の型定義完全ガイドを、Hooksの型定義についてはTypeScript × React Hooks の型定義完全ガイドもあわせてご覧ください。

スポンサーリンク

ZustandとRedux Toolkitの違い

どちらを選ぶか迷ったときのために、主な違いを整理します。

比較項目 Zustand Redux Toolkit
バンドルサイズ ~2KB(超軽量) ~20KB
セットアップ ほぼゼロ。create()だけで始められる slice・reducer・Provider等の準備が必要
TypeScript 型推論が強力。ジェネリクス不要なケースが多い 型定義は充実しているが記述量が多い
DevTools ミドルウェアで追加 デフォルトで統合
学習コスト 低い(Flux/Redux概念不要) 高め(action・reducer・selector の概念が必要)
向いているプロジェクト 中小規模・スピード重視のプロジェクト 大規模・厳格なパターンが必要なプロジェクト
Zustandを選ぶ基準
コンポーネントツリーをまたぐ状態が必要だが、Reduxの学習コストや定型文が重いと感じる場合にZustandは最適です。特にNext.js・Viteを使ったモダンなReactプロジェクトでは現在最もおすすめできる状態管理ライブラリです。

インストール

ターミナル
npm install zustand

# Immerを使う場合(後述)
npm install immer

Zustand v5以降はTypeScriptの型定義が本体に同梱されています。@types/zustandは不要です。

基本的なストアの作成

Zustandのストアはcreate()関数で作成します。TypeScriptではstateとactionsを一つのinterfaceにまとめるのがベストプラクティスです。

src/stores/counterStore.ts
import { create } from "zustand";

// ストアの型定義:stateとactionsを一つのinterfaceで管理
interface CounterStore {
  // State
  count: number;
  step: number;

  // Actions
  increment: () => void;
  decrement: () => void;
  incrementBy: (amount: number) => void;
  reset: () => void;
  setStep: (step: number) => void;
}

export const useCounterStore = create<CounterStore>()((set, get) => ({
  // 初期state
  count: 0,
  step: 1,

  // Actions
  increment: () => set((state) => ({ count: state.count + state.step })),
  decrement: () => set((state) => ({ count: state.count - state.step })),
  incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
  reset: () => set({ count: 0 }),
  setStep: (step) => set({ step }),

  // get() で現在のstateを参照できる
  // 例: get().count
}));
create<Type>()(fn)のカリー化について
Zustand v4以降ではcreate<T>()(fn)のように括弧が2つ必要です。これはTypeScriptのジェネリクス推論のための仕様です。create<T>(fn)と書くとTypeScriptが正しく推論できないため、必ずカリー化形式を使ってください。

Reactコンポーネントでの使い方

src/components/Counter.tsx
import { useCounterStore } from "../stores/counterStore";

export function Counter() {
  // 必要なstateとactionだけを取得(再レンダリング最適化)
  const count = useCounterStore((state) => state.count);
  const { increment, decrement, reset } = useCounterStore();

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>リセット</button>
    </div>
  );
}

Zustandはコンテキストプロバイダー(Provider)が不要です。フックを呼び出すだけで任意のコンポーネントからストアにアクセスできます。

実務的なストア設計:ユーザー管理の例

実際のアプリに近いユーザー管理ストアを例に、型設計のパターンを解説します。

src/stores/userStore.ts
import { create } from "zustand";

// ドメイン型の定義
interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user" | "guest";
}

// ローディング・エラー状態の型
type AsyncStatus = "idle" | "loading" | "success" | "error";

interface UserStore {
  // State
  users: User[];
  selectedUser: User | null;
  status: AsyncStatus;
  error: string | null;

  // Actions(同期)
  selectUser: (user: User | null) => void;
  removeUser: (id: number) => void;
  clearError: () => void;

  // Actions(非同期)
  fetchUsers: () => Promise<void>;
  createUser: (data: Omit<User, "id">) => Promise<void>;
  updateUser: (id: number, data: Partial<User>) => Promise<void>;
}

export const useUserStore = create<UserStore>()((set, get) => ({
  users: [],
  selectedUser: null,
  status: "idle",
  error: null,

  selectUser: (user) => set({ selectedUser: user }),
  removeUser: (id) =>
    set((state) => ({ users: state.users.filter((u) => u.id !== id) })),
  clearError: () => set({ error: null }),

  fetchUsers: async () => {
    set({ status: "loading", error: null });
    try {
      const res = await fetch("https://api.example.com/users");
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const users: User[] = await res.json();
      set({ users, status: "success" });
    } catch (e) {
      set({ status: "error", error: (e as Error).message });
    }
  },

  createUser: async (data) => {
    set({ status: "loading" });
    try {
      const res = await fetch("https://api.example.com/users", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });
      const newUser: User = await res.json();
      set((state) => ({ users: [...state.users, newUser], status: "success" }));
    } catch (e) {
      set({ status: "error", error: (e as Error).message });
    }
  },

  updateUser: async (id, data) => {
    // 楽観的更新(UIを先に更新し、失敗時にロールバック)
    const previous = get().users;
    set((state) => ({
      users: state.users.map((u) => (u.id === id ? { ...u, ...data } : u)),
    }));
    try {
      await fetch(`https://api.example.com/users/${id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });
    } catch (e) {
      // 失敗時は元に戻す
      set({ users: previous, error: (e as Error).message });
    }
  },
}));

非同期処理の型定義全般についてはTypeScript 非同期処理の型定義 完全ガイドも参照してください。

セレクターで再レンダリングを最適化する

Zustandはデフォルトで、ストアの参照した値が変わったコンポーネントのみ再レンダリングします。セレクター関数を正しく書くことで、不要な再レンダリングを防げます。

TypeScript
// NG: ストア全体を取得すると、何が変わっても再レンダリングされる
const store = useUserStore();
const users = store.users;

// OK: セレクターで必要なstateだけ取得する
const users = useUserStore((state) => state.users);
const status = useUserStore((state) => state.status);

// 複数の値を一度に取得(オブジェクトを返すと毎回新しい参照が作られるため注意)
import { useShallow } from "zustand/react/shallow";

// useShallow でオブジェクト・配列を浅い比較でメモ化
const { users, status } = useUserStore(
  useShallow((state) => ({ users: state.users, status: state.status }))
);
オブジェクトを返すセレクターに注意
(state) => ({ users: state.users, status: state.status })のようにオブジェクトを返すセレクターは、毎回新しいオブジェクト参照を返すため常に再レンダリングが発生します。Zustand v4から提供されているuseShallowを使うと浅い比較(shallow compare)でメモ化できます。
TypeScript(派生状態のセレクター)
// 派生状態を計算するセレクター
const adminCount = useUserStore(
  (state) => state.users.filter((u) => u.role === "admin").length
);

// 再利用可能なセレクター関数として切り出す
const selectAdmins = (state: UserStore) =>
  state.users.filter((u) => u.role === "admin");

const admins = useUserStore(selectAdmins);

Immerミドルウェアでイミュータブル更新を簡略化

ネストしたオブジェクトを更新するときは、スプレッド演算子を何重にも書く必要があります。immerミドルウェアを使うとミュータブルな書き方でイミュータブルな更新が実現できます。

TypeScript(Immerなし:ネストが深いと辛い)
interface ProfileStore {
  user: {
    name: string;
    address: {
      city: string;
      zip: string;
    };
  };
  updateCity: (city: string) => void;
}

// Immerなし:スプレッドが深くて読みにくい
updateCity: (city) => set((state) => ({
  user: {
    ...state.user,
    address: { ...state.user.address, city },
  },
})),
TypeScript(Immerあり:ミュータブルに書ける)
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";

export const useProfileStore = create<ProfileStore>()(immer((set) => ({
  user: {
    name: "田中太郎",
    address: { city: "東京", zip: "100-0001" },
  },

  // Immerあり:直接書き換えてOK
  updateCity: (city) => set((state) => {
    state.user.address.city = city; // ミュータブルに書ける
  }),
})));

DevToolsミドルウェア

Redux DevTools Extensionを使うと、状態の変化をブラウザで確認できます。

src/stores/userStore.ts(DevTools追加)
import { create } from "zustand";
import { devtools } from "zustand/middleware";

export const useUserStore = create<UserStore>()(
  devtools(
    (set, get) => ({
      // ストアの実装...
      fetchUsers: async () => {
        set({ status: "loading" }, false, "fetchUsers/pending");
        //                        ↑ replace  ↑ アクション名(DevToolsに表示される)
        try {
          const users = await fetchUsersApi();
          set({ users, status: "success" }, false, "fetchUsers/fulfilled");
        } catch {
          set({ status: "error" }, false, "fetchUsers/rejected");
        }
      },
    }),
    { name: "UserStore" } // DevToolsに表示されるストア名
  )
);

persistミドルウェアでLocalStorageに保存

ユーザーの設定やトークンなど、ページをリロードしても保持したい状態はpersistミドルウェアで自動的にLocalStorageに保存できます。

src/stores/settingsStore.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";

interface SettingsStore {
  theme: "light" | "dark" | "system";
  language: "ja" | "en";
  notificationsEnabled: boolean;
  setTheme: (theme: SettingsStore["theme"]) => void;
  setLanguage: (lang: SettingsStore["language"]) => void;
  toggleNotifications: () => void;
}

export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: "system",
      language: "ja",
      notificationsEnabled: true,

      setTheme: (theme) => set({ theme }),
      setLanguage: (lang) => set({ language: lang }),
      toggleNotifications: () =>
        set((state) => ({ notificationsEnabled: !state.notificationsEnabled })),
    }),
    {
      name: "app-settings",        // LocalStorageのキー名
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({    // 保存するstateを絞り込む
        theme: state.theme,
        language: state.language,
      }),
    }
  )
);

partializeを使うと保存するstateを選択できます。sensitiveな情報やキャッシュデータなど、永続化が不要なstateを除外するために使います。

複数ミドルウェアの組み合わせ

devtools・persist・immerを組み合わせる場合は、型が正しく推論されるよう適切な順序で適用する必要があります。

TypeScript(ミドルウェアの組み合わせ)
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";

// 推奨順序: devtools → persist → immer
export const useStore = create<MyStore>()(
  devtools(
    persist(
      immer((set) => ({
        // ストアの実装
      })),
      { name: "my-store" }
    ),
    { name: "MyStore" }
  )
);

スライスパターンで大規模ストアを設計する

アプリが大きくなると、一つのストアに全ての状態を詰め込むと管理しにくくなります。スライスパターンは、ストアを機能ごとのスライスに分割してから統合する方法です。

src/stores/slices/userSlice.ts
import { StateCreator } from "zustand";

interface User {
  id: number;
  name: string;
}

export interface UserSlice {
  users: User[];
  currentUser: User | null;
  setCurrentUser: (user: User | null) => void;
  addUser: (user: User) => void;
}

// StateCreator<全体の型, ミドルウェア, ミドルウェア, このスライスの型>
export const createUserSlice: StateCreator<
  UserSlice & CartSlice,
  [],
  [],
  UserSlice
> = (set) => ({
  users: [],
  currentUser: null,
  setCurrentUser: (user) => set({ currentUser: user }),
  addUser: (user) => set((state) => ({ users: [...state.users, user] })),
});
src/stores/slices/cartSlice.ts
import { StateCreator } from "zustand";

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

export interface CartSlice {
  items: CartItem[];
  total: number;
  addToCart: (item: Omit<CartItem, "quantity">) => void;
  removeFromCart: (id: number) => void;
  clearCart: () => void;
}

export const createCartSlice: StateCreator<
  UserSlice & CartSlice,
  [],
  [],
  CartSlice
> = (set, get) => ({
  items: [],
  total: 0,

  addToCart: (item) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === item.id);
      const newItems = existing
        ? state.items.map((i) =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          )
        : [...state.items, { ...item, quantity: 1 }];
      return {
        items: newItems,
        total: newItems.reduce((s, i) => s + i.price * i.quantity, 0),
      };
    }),

  removeFromCart: (id) =>
    set((state) => {
      const newItems = state.items.filter((i) => i.id !== id);
      return {
        items: newItems,
        total: newItems.reduce((s, i) => s + i.price * i.quantity, 0),
      };
    }),

  clearCart: () => set({ items: [], total: 0 }),
});
src/stores/useStore.ts(スライスを統合)
import { create } from "zustand";
import { createUserSlice, UserSlice } from "./slices/userSlice";
import { createCartSlice, CartSlice } from "./slices/cartSlice";

// 全スライスの型を合成
type BoundStore = UserSlice & CartSlice;

export const useStore = create<BoundStore>()((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}));

// 各スライスへの型安全なアクセス
export const useUserSlice = () => useStore(useShallow((s) => ({
  users: s.users,
  currentUser: s.currentUser,
  setCurrentUser: s.setCurrentUser,
})));

export const useCartSlice = () => useStore(useShallow((s) => ({
  items: s.items,
  total: s.total,
  addToCart: s.addToCart,
  clearCart: s.clearCart,
})));

ストアをテストする

ZustandはReactに依存しないため、Vitestで直接ストアをテストできます。

ターミナル
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom jsdom
src/stores/__tests__/counterStore.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { useCounterStore } from "../counterStore";

describe("useCounterStore", () => {
  // 各テスト前にストアをリセット
  beforeEach(() => {
    useCounterStore.setState({ count: 0, step: 1 });
  });

  it("初期値が0であること", () => {
    expect(useCounterStore.getState().count).toBe(0);
  });

  it("increment でstepの分だけ増加すること", () => {
    useCounterStore.setState({ step: 5 });
    useCounterStore.getState().increment();
    expect(useCounterStore.getState().count).toBe(5);
  });

  it("reset で0に戻ること", () => {
    useCounterStore.setState({ count: 100 });
    useCounterStore.getState().reset();
    expect(useCounterStore.getState().count).toBe(0);
  });

  it("incrementBy で指定した数だけ増加すること", () => {
    useCounterStore.getState().incrementBy(7);
    expect(useCounterStore.getState().count).toBe(7);
  });
});

useStore.setState()でストアの状態を直接書き換えられるため、テストのセットアップが非常に簡単です。テスト全般についてはTypeScript Jest・Vitest テスト完全ガイドも参照してください。

Reactコンポーネントのテスト

src/components/__tests__/Counter.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, beforeEach } from "vitest";
import { Counter } from "../Counter";
import { useCounterStore } from "../../stores/counterStore";

describe("Counter", () => {
  beforeEach(() => {
    useCounterStore.setState({ count: 0, step: 1 });
  });

  it("+ボタンでカウントが増加する", () => {
    render(<Counter />);
    const plusBtn = screen.getByText("+");
    fireEvent.click(plusBtn);
    expect(screen.getByText("カウント: 1")).toBeInTheDocument();
  });

  it("リセットボタンで0に戻る", () => {
    useCounterStore.setState({ count: 5 });
    render(<Counter />);
    fireEvent.click(screen.getByText("リセット"));
    expect(screen.getByText("カウント: 0")).toBeInTheDocument();
  });
});

ストアをReact外から操作する

AxiosのインターセプターやWebSocketハンドラーなど、Reactコンポーネント外でストアを操作したい場面があります。useStore.getState()useStore.setState()を使えばフック外でもストアにアクセスできます。

src/lib/apiClient.ts
import axios from "axios";
import { useUserStore } from "../stores/userStore";

const apiClient = axios.create({ baseURL: "https://api.example.com" });

// Reactコンポーネント外でストアにアクセス
apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      // コンポーネント外からストアを操作
      useUserStore.setState({ selectedUser: null, status: "idle" });
      window.location.href = "/login";
    }
    return Promise.reject(error);
  }
);

export default apiClient;

よくある型エラーと対処法

型エラー①:set関数の型が合わない

NG
// NG: set に存在しないプロパティを渡すとエラー
set({ nonExistentProp: "value" }); // Property 'nonExistentProp' does not exist
OK
// OK: interfaceで定義したプロパティのみセット可能
set({ count: 0 }); // CounterStore["count"] は number 型
set({ count: "0" }); // 型エラー:string は number に代入不可

型エラー②:create<T>(fn)の古い書き方

NG(v4以前の書き方)
// NG: v4以降では型推論が正しく効かない
const useStore = create<MyStore>((set) => ({...}));
OK(カリー化)
// OK: create<T>()(...) のカリー化形式を使う
const useStore = create<MyStore>()((set) => ({...}));

型エラー③:ミドルウェアの型推論が壊れる

TypeScript
// ミドルウェアが多くなると型エラーが出ることがある
// 解決策: 明示的に StateCreator 型を指定する
import { StateCreator } from "zustand";

const mySlice: StateCreator<MyStore> = (set, get) => ({
  // 型エラーを避けるために StateCreator に型を渡す
  someAction: () => set({ count: 0 }),
});

型エラー④:persist後にhydrationが必要

TypeScript
import { useSettingsStore } from "../stores/settingsStore";

// SSR環境(Next.js等)ではhydration前にstoreへのアクセスで不整合が起きることがある
// 解決策: onRehydrateStorage コールバックを使う
export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({ /* ... */ }),
    {
      name: "app-settings",
      onRehydrateStorage: () => (state) => {
        console.log("hydration finished, state:", state);
      },
    }
  )
);

まとめ

ZustandをTypeScriptで使う際のポイントをまとめます。

項目 ポイント
ストア型定義 stateとactionsを一つのinterfaceにまとめる。create<T>()()のカリー化形式を使う
セレクター 必要なstateだけ取得して再レンダリングを最小化。複数取得はuseShallowで浅い比較
非同期処理 actionにasync/awaitを直接書ける。ローディング・エラー状態をAsyncStatus型で管理
Immer ネストが深いstateはImmerミドルウェアでミュータブルに更新できる
DevTools devtoolsミドルウェア + Redux DevTools ExtensionでReduxと同じDXが得られる
persist LocalStorageへの保存はpersistミドルウェアで簡単に実現。partializeで保存対象を絞る
スライスパターン 大規模アプリはStateCreatorでスライス分割して結合する
テスト useStore.setState()でストアを直接操作でき、セットアップが簡単

ZustandはシンプルなAPIながら、型安全性・パフォーマンス・テスタビリティの三点を高いレベルで実現しています。プロバイダーの記述が不要でコンポーネント外からも操作できる点も、設計の自由度を高めてくれます。

判別可能なUnion型を活用したstateの設計についてはTypeScript 判別可能なユニオン型(Discriminated Unions)完全ガイドも参考になります。