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と直接アクセスすると、userがnullの場合にランタイムエラーになります。オプショナルチェーン(?.)を活用してください。
実務で効果的なストア設計パターン
この章では、プロジェクト規模に応じた具体的な設計パターンを紹介します。
スライスパターンによる大規模ストアの分割
大規模アプリケーションでは、ストアを論理的な単位(スライス)に分割します。以下の図は、スライスパターンの構造を示しています。
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 ミドルウェアにより型安全性と可読性が向上します。
実務では、最初から完璧な型設計を目指すよりも、小さく始めて段階的にリファクタリングするアプローチが現実的です。型エラーが発生した際は、それを改善のシグナルと捉え、設計を見直す機会としてください。
関連リンク
著書
article2026年1月22日ZustandとTypeScriptのユースケース ストアを型安全に設計して運用する実践
article本番計測とトレース:Zustand の更新頻度・差分サイズを可視化して改善サイクル化
articleCRDT × Zustand:Y.js/Automerge 連携でリアルタイム共同編集を設計
articlezustand Middleware 合成チートシート:logger・persist・immer・subscribeWithSelector の重ね方
articleStorybook で Zustand をモックする:Controls 連動とシナリオ駆動 UI
article伝搬方式を比較:Zustand の selector/derived-middleware/外部 reselect の使い分け
article2026年1月23日TypeScriptのtypeとinterfaceを比較・検証する 違いと使い分けの判断基準を整理
article2026年1月23日TypeScript 5.8の型推論を比較・検証する 強化点と落とし穴の回避策
article2026年1月23日TypeScript Genericsの使用例を早見表でまとめる 記法と頻出パターンを整理
article2026年1月22日TypeScriptの型システムを概要で理解する 基礎から全体像まで完全解説
article2026年1月22日ZustandとTypeScriptのユースケース ストアを型安全に設計して運用する実践
article2026年1月22日TypeScriptでよく出るエラーをトラブルシュートでまとめる 原因と解決法30選
articleshadcn/ui × TanStack Table 設計術:仮想化・列リサイズ・アクセシブルなグリッド
articleRemix のデータ境界設計:Loader・Action とクライアントコードの責務分離
articlePreact コンポーネント設計 7 原則:再レンダリング最小化の分割と型付け
articlePHP 8.3 の新機能まとめ:readonly クラス・型強化・性能改善を一気に理解
articleNotebookLM に PDF/Google ドキュメント/URL を取り込む手順と最適化
articlePlaywright 並列実行設計:shard/grep/fixtures で高速化するテストスイート設計術
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
