T-CREATOR

<div />

ZustandとTypeScriptのユースケース ストアを型安全に設計して運用する実践

2026年1月22日
ZustandとTypeScriptのユースケース ストアを型安全に設計して運用する実践

Zustand と TypeScript を組み合わせた状態管理において、「型推論を最大限に活かしながら、セレクタやアクションの型が崩れない設計」を実現する方法を解説します。実務でストアを運用するなかで直面した型安全性の課題と、その解決策を具体的なコードとともに紹介します。

Zustand の型定義アプローチ比較

アプローチ型推論保守性初学者向け適用規模
暗黙的型推論(型定義なし)△ 部分的× 低い○ 簡単小規模
ジェネリクス明示(create<T>)○ 完全○ 高い△ 中程度中〜大規模
StateCreator + スライス分割○ 完全◎ 最高× 難しい大規模
interface 継承パターン○ 完全○ 高い○ 理解しやすい中規模

それぞれの詳細は後述します。

検証環境

  • OS: macOS Sequoia 15.2
  • Node.js: 24.13.0 (LTS)
  • TypeScript: 5.9.3
  • 主要パッケージ:
    • zustand: 5.0.10
    • react: 19.0.0
    • immer: 10.1.1
  • 検証日: 2026 年 01 月 22 日

Zustand で型安全な状態管理が求められる背景

この章では、なぜ Zustand と TypeScript の組み合わせにおいて型安全性が重要になるのかを説明します。

グローバルステートの型が崩れるリスク

React アプリケーションが成長すると、状態管理の複雑さも比例して増加します。Zustand はシンプルな API を持つ状態管理ライブラリですが、TypeScript と組み合わせる際には意図的な型設計が必要です。

型推論(コンパイラが自動的に型を判定する仕組み)に完全に頼ると、以下のような問題が発生します。

typescript// 暗黙的な型推論のみに頼った例
const useStore = create((set) => ({
  user: null, // null として推論される(User | null ではない)
  setUser: (user) => set({ user }), // user は any になりやすい
}));

このコードでは user が単なる null 型として推論され、後から User オブジェクトを代入しても型チェックが効きません。実際に検証したところ、この書き方では IDE のコード補完も正しく機能せず、開発効率が大幅に低下しました。

従来の状態管理ライブラリとの違い

Redux などの従来のライブラリでは、アクション・リデューサー・セレクターそれぞれに型定義が必要でした。Zustand はこの冗長さを解消していますが、逆に「どこで型を定義すべきか」が曖昧になりがちです。

以下の図は、Zustand における型定義の流れを示しています。

mermaidflowchart LR
  interface["interface 定義"] --> create["create<T>()"]
  create --> store["型付きストア"]
  store --> selector["セレクタ"]
  store --> action["アクション"]
  selector --> component["コンポーネント"]
  action --> component

interface で状態とアクションの型を定義し、create<T>() でストアに適用することで、セレクタやアクションの型が一貫して保たれます。

型安全性が損なわれる典型的な課題

この章では、実務で遭遇した型安全性に関する具体的な問題を紹介します。

セレクタの戻り値型が any になる問題

ストアの型定義が不十分だと、セレクタ関数(ストアから特定の値を取り出す関数)の戻り値が any になります。

typescript// 問題のあるコード
const userName = useStore((state) => state.user?.name);
// userName の型が any になる可能性がある

つまずきやすい点:セレクタの型エラーは実行時まで発覚しないことが多い。IDE で補完が効かない場合は、ストアの型定義を見直す必要があります。

アクションの引数型が推論されない問題

アクション関数の引数型も、明示的に定義しないと any になります。

typescript// 問題のあるコード
const useStore = create((set) => ({
  updateUser: (data) => set({ user: data }), // data は any
}));

業務で問題になったケースとして、updateUser に誤った型のオブジェクトを渡してもコンパイルエラーにならず、本番環境でランタイムエラーが発生したことがありました。

ネスト構造での型の崩壊

深くネストされた状態を更新する際、スプレッド構文の連鎖で型が崩れやすくなります。

typescript// ネストが深い更新
set((state) => ({
  ui: {
    ...state.ui,
    sidebar: {
      ...state.ui.sidebar,
      settings: {
        ...state.ui.sidebar.settings, // ここで型が any になることがある
        width: 300,
      },
    },
  },
}));

検証の結果、3 階層以上のネストでは Immer(イミュータブルな更新を簡潔に書けるライブラリ)の導入が型安全性の維持に効果的でした。

型安全なストア設計の解決策

この章では、型推論を活かしながら型安全性を確保する具体的な設計パターンを解説します。

interface による状態とアクションの分離定義

最も基本的かつ効果的なアプローチは、状態とアクションを別々の interface で定義し、交差型(&)で結合する方法です。

typescript// 状態の型定義
interface UserState {
  user: User | null;
  isLoading: boolean;
  error: string | null;
}

// アクションの型定義
interface UserActions {
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  updateProfile: (updates: Partial<User>) => Promise<void>;
}

// ストア全体の型
type UserStore = UserState & UserActions;

この分離により、状態の形状とアクションのシグネチャが明確になり、保守性が向上します。

create 関数へのジェネリクス適用

定義した型を create 関数のジェネリクスとして渡すことで、完全な型推論が有効になります。

typescriptimport { create } from "zustand";

const useUserStore = create<UserStore>((set, get) => ({
  // 状態の初期値
  user: null,
  isLoading: false,
  error: null,

  // アクションの実装
  login: async (email, password) => {
    set({ isLoading: true, error: null });
    try {
      const user = await apiLogin(email, password);
      set({ user, isLoading: false });
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : "不明なエラー",
        isLoading: false,
      });
    }
  },

  logout: () => set({ user: null }),

  updateProfile: async (updates) => {
    const currentUser = get().user;
    if (!currentUser) return;

    set({ isLoading: true });
    const updatedUser = await apiUpdateProfile(currentUser.id, updates);
    set({ user: updatedUser, isLoading: false });
  },
}));

この書き方では、set 関数に渡すオブジェクトや get 関数の戻り値が正しく型付けされます。

型安全なセレクタの作成

セレクタを別途定義することで、コンポーネント側での型安全性と再利用性が向上します。

typescript// セレクタの定義
const selectUser = (state: UserStore) => state.user;
const selectIsAuthenticated = (state: UserStore) => state.user !== null;
const selectUserName = (state: UserStore) => state.user?.name ?? "ゲスト";

// コンポーネントでの使用
function UserProfile() {
  const user = useUserStore(selectUser);
  const isAuthenticated = useUserStore(selectIsAuthenticated);
  const userName = useUserStore(selectUserName);

  // user は User | null として正しく型付けされる
  // userName は string として型付けされる
}

つまずきやすい点:セレクタ内で state.user.name と直接アクセスすると、usernull の場合にランタイムエラーになります。オプショナルチェーン(?.)を活用してください。

実務で効果的なストア設計パターン

この章では、プロジェクト規模に応じた具体的な設計パターンを紹介します。

スライスパターンによる大規模ストアの分割

大規模アプリケーションでは、ストアを論理的な単位(スライス)に分割します。以下の図は、スライスパターンの構造を示しています。

mermaidflowchart TB
  subgraph store["統合ストア"]
    root["useStore"]
  end

  subgraph slices["スライス"]
    user["UserSlice"]
    ui["UISlice"]
    cart["CartSlice"]
  end

  user --> root
  ui --> root
  cart --> root

  root --> components["コンポーネント"]

各スライスが独立した状態とアクションを持ち、最終的に 1 つのストアに統合されます。

typescriptimport { create, StateCreator } from "zustand";

// 各スライスの型定義
interface UserSlice {
  user: User | null;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
}

interface UISlice {
  theme: "light" | "dark";
  sidebarOpen: boolean;
  toggleTheme: () => void;
  toggleSidebar: () => void;
}

// 統合ストアの型
type RootStore = UserSlice & UISlice;

// スライスの作成関数
const createUserSlice: StateCreator<RootStore, [], [], UserSlice> = (
  set,
  get,
) => ({
  user: null,
  login: async (credentials) => {
    const user = await apiLogin(credentials);
    set({ user });
  },
  logout: () => set({ user: null }),
});

const createUISlice: StateCreator<RootStore, [], [], UISlice> = (set) => ({
  theme: "light",
  sidebarOpen: false,
  toggleTheme: () =>
    set((state) => ({
      theme: state.theme === "light" ? "dark" : "light",
    })),
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
});

// ストアの統合
const useStore = create<RootStore>()((...args) => ({
  ...createUserSlice(...args),
  ...createUISlice(...args),
}));

実際に採用した理由として、チーム開発において各メンバーが担当するスライスを独立して開発でき、コンフリクトが大幅に減少しました。

Immer ミドルウェアによるネスト更新の型安全化

深いネスト構造を持つ状態の更新には、Immer ミドルウェアが効果的です。

typescriptimport { create } from "zustand";
import { immer } from "zustand/middleware/immer";

interface SettingsState {
  settings: {
    display: {
      theme: "light" | "dark";
      fontSize: number;
      sidebar: {
        width: number;
        collapsed: boolean;
      };
    };
    notifications: {
      email: boolean;
      push: boolean;
    };
  };
  updateSidebarWidth: (width: number) => void;
  toggleSidebarCollapsed: () => void;
}

const useSettingsStore = create<SettingsState>()(
  immer((set) => ({
    settings: {
      display: {
        theme: "light",
        fontSize: 14,
        sidebar: {
          width: 240,
          collapsed: false,
        },
      },
      notifications: {
        email: true,
        push: false,
      },
    },

    // Immer により直接的な変更が可能
    updateSidebarWidth: (width) =>
      set((state) => {
        state.settings.display.sidebar.width = width;
      }),

    toggleSidebarCollapsed: () =>
      set((state) => {
        state.settings.display.sidebar.collapsed =
          !state.settings.display.sidebar.collapsed;
      }),
  })),
);

検証中に起きた失敗として、Immer なしで同様の更新を行った際にスプレッド構文の記述ミスで型エラーが発生せず、ランタイムで undefined が混入した経験があります。

動的キーを持つストアの型定義

エンティティのコレクションを管理する場合、Record 型と keyof を活用します。

typescriptinterface Entity {
  id: string;
  name: string;
  createdAt: Date;
}

interface EntitiesState {
  entities: Record<string, Entity>;
  selectedId: string | null;

  addEntity: (entity: Entity) => void;
  updateEntity: (id: string, updates: Partial<Omit<Entity, "id">>) => void;
  removeEntity: (id: string) => void;
  selectEntity: (id: string | null) => void;
}

const useEntitiesStore = create<EntitiesState>((set) => ({
  entities: {},
  selectedId: null,

  addEntity: (entity) =>
    set((state) => ({
      entities: { ...state.entities, [entity.id]: entity },
    })),

  updateEntity: (id, updates) =>
    set((state) => {
      const existing = state.entities[id];
      if (!existing) return state;

      return {
        entities: {
          ...state.entities,
          [id]: { ...existing, ...updates },
        },
      };
    }),

  removeEntity: (id) =>
    set((state) => {
      const { [id]: removed, ...rest } = state.entities;
      return { entities: rest };
    }),

  selectEntity: (id) => set({ selectedId: id }),
}));

非同期アクションの型安全な実装

API 呼び出しを伴う非同期アクションでは、ローディング状態とエラー処理の型も定義します。

typescriptinterface AsyncState<T> {
  data: T | null;
  status: "idle" | "loading" | "success" | "error";
  error: string | null;
}

interface ProductStore extends AsyncState<Product[]> {
  fetchProducts: () => Promise<void>;
  addProduct: (product: Omit<Product, "id">) => Promise<void>;
}

const useProductStore = create<ProductStore>((set, get) => ({
  data: null,
  status: "idle",
  error: null,

  fetchProducts: async () => {
    set({ status: "loading", error: null });
    try {
      const products = await api.getProducts();
      set({ data: products, status: "success" });
    } catch (error) {
      set({
        status: "error",
        error: error instanceof Error ? error.message : "取得に失敗しました",
      });
    }
  },

  addProduct: async (productData) => {
    const currentData = get().data;
    try {
      const newProduct = await api.createProduct(productData);
      set({
        data: currentData ? [...currentData, newProduct] : [newProduct],
      });
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : "追加に失敗しました",
      });
    }
  },
}));

ファイル構成とエクスポートパターン

この章では、保守性を高めるファイル構成を紹介します。

推奨ディレクトリ構造

bashsrc/
├── store/
│   ├── index.ts          # ストアのエクスポート
│   ├── types.ts          # 型定義の集約
│   ├── slices/
│   │   ├── userSlice.ts
│   │   ├── uiSlice.ts
│   │   └── cartSlice.ts
│   └── selectors/
│       ├── userSelectors.ts
│       └── cartSelectors.ts

型定義ファイルの構成例

typescript// store/types.ts
export interface User {
  id: string;
  name: string;
  email: string;
}

export interface UserState {
  user: User | null;
  isLoading: boolean;
  error: string | null;
}

export interface UserActions {
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

export type UserSlice = UserState & UserActions;

// 他のスライスの型定義も同様に記述

export type RootStore = UserSlice & UISlice & CartSlice;
typescript// store/index.ts
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import type { RootStore } from "./types";
import { createUserSlice } from "./slices/userSlice";
import { createUISlice } from "./slices/uiSlice";
import { createCartSlice } from "./slices/cartSlice";

export const useStore = create<RootStore>()(
  devtools(
    persist(
      immer((...args) => ({
        ...createUserSlice(...args),
        ...createUISlice(...args),
        ...createCartSlice(...args),
      })),
      {
        name: "app-store",
        partialize: (state) => ({
          user: state.user,
          theme: state.theme,
        }),
      },
    ),
  ),
);

// 型エクスポート
export type { RootStore, User, UserState } from "./types";

型定義アプローチの詳細比較

冒頭の比較表で示した各アプローチについて、詳細な判断基準を解説します。

Zustand の型定義アプローチ詳細比較

アプローチ型推論保守性初学者向け適用規模ユースケース
暗黙的型推論△ 部分的× 低い○ 簡単小規模プロトタイプ、学習用
ジェネリクス明示○ 完全○ 高い△ 中程度中〜大規模一般的なプロダクション
StateCreator + スライス○ 完全◎ 最高× 難しい大規模チーム開発、複雑なドメイン
interface 継承○ 完全○ 高い○ 理解しやすい中規模状態とアクションの分離が明確な場合

暗黙的型推論が向いているケース

  • 学習目的やプロトタイプ開発
  • 状態が 5 つ以下の小規模ストア
  • 短期間で破棄するコード

ジェネリクス明示が向いているケース

  • プロダクション環境での使用
  • IDE の補完機能を最大限に活用したい場合
  • 中規模以上のアプリケーション

StateCreator + スライスが向いているケース

  • 複数人でのチーム開発
  • ドメインが複雑で状態が多岐にわたる場合
  • 長期的な保守が見込まれるプロジェクト

interface 継承が向いているケース

  • 状態とアクションを明確に分離したい場合
  • 型定義の可読性を重視する場合
  • TypeScript の基本的な型システムに沿った設計を好む場合

採用しなかった案として、type のみでの定義も検討しましたが、interface の方が拡張性が高く、エラーメッセージも読みやすいため、状態の定義には interface を採用しました。

まとめ

Zustand と TypeScript を組み合わせた型安全なストア設計について解説しました。

型推論を活かしつつ型安全性を維持するには、以下のポイントが重要です。

  • 状態とアクションは interface で明示的に定義する:暗黙的な型推論に頼らず、create<T>() のジェネリクスに型を渡すことで、セレクタやアクションの型が一貫して保たれます。

  • プロジェクト規模に応じたパターンを選択する:小規模であればシンプルなジェネリクス明示、大規模であればスライスパターンと StateCreator の組み合わせが効果的です。

  • ネスト構造には Immer を検討する:3 階層以上のネストでは、Immer ミドルウェアにより型安全性と可読性が向上します。

実務では、最初から完璧な型設計を目指すよりも、小さく始めて段階的にリファクタリングするアプローチが現実的です。型エラーが発生した際は、それを改善のシグナルと捉え、設計を見直す機会としてください。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;