Zustand × TypeScript:型安全にストアを構築する設計

React アプリケーションの品質とメンテナンス性を高めるうえで、型安全性は重要な要素です。Zustand は、そのシンプルな API に加えて、TypeScript との優れた相性で多くの開発者から支持されています。この記事では、Zustand と TypeScript を組み合わせて、型安全なグローバルステート管理を実現する方法を深掘りしていきます。
はじめに
Zustand と TypeScript の相性の良さ
Zustand は、最初から TypeScript を念頭に置いて設計されたステート管理ライブラリです。そのシンプルな API は、TypeScript の型システムとシームレスに統合され、強力な型推論と型チェックの恩恵を受けやすくなっています。
例えば、以下のようなシンプルなストア定義でも、TypeScript の型推論がうまく機能します:
typescriptimport { create } from 'zustand';
// 型定義なしでもある程度の型推論が効く
const useStore = create((set) => ({
count: 0,
increment: () =>
set((state) => ({ count: state.count + 1 })),
}));
このコードでも基本的な型推論は機能しますが、明示的な型付けによってさらに強力な型安全性を得ることができます。
型安全なグローバルステート管理の重要性
グローバルステート管理において型安全性が重要な理由は以下の点にあります:
- バグの早期発見 - コンパイル時にエラーを検出できる
- リファクタリングの安全性 - 型チェックによってコード変更の影響範囲を把握できる
- 自己文書化 - 型定義自体がコードの仕様を表現する
- 開発体験の向上 - IDE のコード補完やヒントが正確になる
- チーム開発の効率化 - 型を通じて API の使用方法を明確に伝達できる
React 開発では、特にアプリケーションの規模が大きくなるにつれ、これらの利点は非常に価値があります。
背景
TypeScript による型付けの基本的なメリット
TypeScript を使用する主なメリットを再確認しておきましょう:
- 静的型チェック - コンパイル時に型エラーを検出
- コード補完とインテリセンス - IDE 機能の強化
- リファクタリングのサポート - 変更の影響範囲の把握
- ドキュメントとしての型 - コードの意図を明確に表現
これらのメリットは、特にチーム開発や大規模なプロジェクトで顕著になります。
従来のステート管理ライブラリでの型定義の課題
従来のステート管理ライブラリ(特に Redux)では、TypeScript との統合に課題がありました:
- 冗長な型定義 - アクション、リデューサー、セレクターなど多くの場所で型を定義する必要があった
- 型定義の同期維持 - 複数の場所で定義された型の整合性を保つのが難しかった
- 複雑な型推論 - ミドルウェアやエンハンサーなどの高度な機能と型システムの統合が難しかった
- 学習曲線の高さ - ライブラリ特有の型パターンの理解が必要だった
Redux Toolkit などの改善はありましたが、それでも型定義の労力は少なくありませんでした。
一方、Zustand はその設計思想自体がシンプルであるため、TypeScript との相性が非常に良く、上記の課題の多くを解決しています。
課題
複雑な型定義におけるボイラープレートの増加
Zustand は基本的な使用においては型定義がシンプルですが、ストアが複雑になるにつれ、型定義も複雑になる傾向があります。例えば:
- ネストされた状態構造
- 複数の非同期アクション
- ミドルウェアの使用
- 複数ストアの連携
これらの要素が増えると、型定義のボイラープレートが増加し、メンテナンスコストが高くなります。
型推論の限界とエラーメッセージの解読困難さ
TypeScript の型推論は強力ですが、複雑なストア設計では限界があります。特に:
- 複雑なジェネリック型 - 多層的なジェネリック型はエラーメッセージが非常に長く複雑になりがち
- union 型の絞り込み - 複雑な条件分岐での型絞り込みが難しい場合がある
- 再帰的な型 - 深くネストされた再帰的データ構造の型定義が複雑になる
これらの問題に対処するには、型定義の戦略とパターンが必要です。
ストアの肥大化に伴う型の管理コスト
ストアが大きくなるにつれ、以下のような課題が生じます:
- 型定義の重複 - 似た構造が複数の場所で定義される
- 型定義の散在 - 関連する型が異なるファイルに分散する
- 型の整合性維持 - 関連する型の間の整合性を保つのが難しくなる
- 型定義の肥大化 - 単一の型定義ファイルが非常に大きくなる
これらの課題は、適切な型設計と構造化によって解決する必要があります。
解決策
Zustand の型定義アーキテクチャ
Zustand では、主に以下の方法で型を定義します:
- ストアの状態型定義:
typescriptinterface StoreState {
count: number;
text: string;
// 他の状態プロパティ
}
- アクションを含む完全なストア型定義:
typescriptinterface StoreWithActions extends StoreState {
increment: () => void;
updateText: (text: string) => void;
// 他のアクション
}
- create 関数へのジェネリクス適用:
typescriptconst useStore = create<StoreWithActions>((set) => ({
// ストアの実装
}));
これは Zustand の基本的な型定義アーキテクチャですが、これを拡張して様々なパターンを適用できます。
型定義のベストプラクティス(interface vs type)
TypeScript ではinterface
とtype
の 2 つの方法で型を定義できますが、Zustand ストアでは以下の使い分けが有効です:
interface を使うケース:
- 拡張性が必要な型(継承が必要な場合)
- 宣言的マージが必要な場合
- オブジェクト形状の定義
typescript// 基本的なストア状態
interface StoreState {
user: {
id: string;
name: string;
};
isAuthenticated: boolean;
}
// アクションを追加した拡張interface
interface StoreWithActions extends StoreState {
login: (
username: string,
password: string
) => Promise<void>;
logout: () => void;
}
type を使うケース:
- ユニオン型、交差型の定義
- 基本型のエイリアス
- マップド型、条件付き型などの高度な型操作
typescript// 可能なユーザー状態のユニオン型
type UserStatus =
| 'idle'
| 'loading'
| 'authenticated'
| 'error';
// 状態とアクションの組み合わせ
type Store = StoreState & {
login: (
username: string,
password: string
) => Promise<void>;
logout: () => void;
};
一般的には、単純なオブジェクト構造にはinterface
を使い、より複雑な型や変換にはtype
を使うのが良いでしょう。
スライスパターンによる型の分割管理
大規模なストアでは、「スライス」と呼ばれる論理的な単位で状態とアクションを分割するパターンが効果的です:
typescript// ユーザースライス
interface UserSlice {
user: User | null;
status: 'idle' | 'loading' | 'error';
error: string | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
// UIスライス
interface UISlice {
theme: 'light' | 'dark';
sidebar: {
isOpen: boolean;
};
toggleTheme: () => void;
toggleSidebar: () => void;
}
// 全体のストア型
type StoreState = UserSlice & UISlice;
スライスパターンを実装するための関数も定義できます:
typescript// スライスを作成するヘルパー関数
const createUserSlice = (
set: SetState<StoreState>,
get: GetState<StoreState>
): UserSlice => ({
user: null,
status: 'idle',
error: null,
login: async (credentials) => {
set({ status: 'loading' });
try {
const user = await apiLogin(credentials);
set({ user, status: 'idle', error: null });
} catch (error) {
set({
status: 'error',
error: error.message,
user: null,
});
}
},
logout: () => {
set({ user: null });
},
});
// メインストアでスライスを組み合わせる
const useStore = create<StoreState>((set, get) => ({
...createUserSlice(set, get),
...createUISlice(set, get),
}));
このパターンにより、関連する状態とアクションを論理的にグループ化でき、型定義も整理できます。
具体例
基本的なストアの型付け
最も基本的なストアの型付けの例です:
typescriptimport { create } from 'zustand';
// ストアの状態とアクションの型定義
interface BearStore {
// 状態
bears: number;
honey: number;
// アクション
increaseBearsBy: (by: number) => void;
increaseHoneyBy: (by: number) => void;
reset: () => void;
}
// 型付きストアの作成
export const useBearStore = create<BearStore>((set) => ({
// 初期状態
bears: 0,
honey: 0,
// アクション実装
increaseBearsBy: (by) =>
set((state) => ({ bears: state.bears + by })),
increaseHoneyBy: (by) =>
set((state) => ({ honey: state.honey + by })),
reset: () => set({ bears: 0, honey: 0 }),
}));
この基本パターンでも、以下の利点があります:
- IDE での自動補完
- 型チェックによるエラー防止
- ストアの自己文書化
ネスト構造を持つ複雑なストアの型定義
より複雑なネスト構造を持つストアの例です:
typescriptimport { create } from 'zustand';
// ネストされた型の定義
interface User {
id: string;
name: string;
preferences: {
theme: 'light' | 'dark';
notifications: {
email: boolean;
push: boolean;
sms: boolean;
};
};
}
interface AppState {
// ネストされた状態
user: User | null;
ui: {
sidebar: {
isOpen: boolean;
width: number;
};
modal: {
isOpen: boolean;
type: 'settings' | 'profile' | 'help' | null;
};
};
}
interface AppActions {
// ユーザーアクション
login: (user: User) => void;
logout: () => void;
updateUserName: (name: string) => void;
updateNotificationPreference: (
channel: keyof User['preferences']['notifications'],
value: boolean
) => void;
setTheme: (theme: User['preferences']['theme']) => void;
// UIアクション
toggleSidebar: () => void;
setSidebarWidth: (width: number) => void;
openModal: (
type: AppState['ui']['modal']['type']
) => void;
closeModal: () => void;
}
// 完全なストア型
type AppStore = AppState & AppActions;
// ストアの作成
export const useAppStore = create<AppStore>((set) => ({
// 初期状態
user: null,
ui: {
sidebar: {
isOpen: false,
width: 240,
},
modal: {
isOpen: false,
type: null,
},
},
// アクション実装
login: (user) => set({ user }),
logout: () => set({ user: null }),
updateUserName: (name) =>
set((state) => ({
user: state.user ? { ...state.user, name } : null,
})),
updateNotificationPreference: (channel, value) =>
set((state) => ({
user: state.user
? {
...state.user,
preferences: {
...state.user.preferences,
notifications: {
...state.user.preferences.notifications,
[channel]: value,
},
},
}
: null,
})),
setTheme: (theme) =>
set((state) => ({
user: state.user
? {
...state.user,
preferences: {
...state.user.preferences,
theme,
},
}
: null,
})),
toggleSidebar: () =>
set((state) => ({
ui: {
...state.ui,
sidebar: {
...state.ui.sidebar,
isOpen: !state.ui.sidebar.isOpen,
},
},
})),
setSidebarWidth: (width) =>
set((state) => ({
ui: {
...state.ui,
sidebar: {
...state.ui.sidebar,
width,
},
},
})),
openModal: (type) =>
set((state) => ({
ui: {
...state.ui,
modal: {
isOpen: true,
type,
},
},
})),
closeModal: () =>
set((state) => ({
ui: {
...state.ui,
modal: {
isOpen: false,
type: null,
},
},
})),
}));
このような複雑なステート構造では、Immer を使うとコードを簡略化できます:
typescriptimport { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
// 上記と同じ型定義
// Immerミドルウェアを使用したストア
export const useAppStore = create<AppStore>()(
immer((set) => ({
// 初期状態は同じ
// Immerを使用したアクション実装例
updateNotificationPreference: (channel, value) =>
set((state) => {
if (state.user) {
// 直接変更できる(Immerがイミュータブルな更新に変換)
state.user.preferences.notifications[channel] =
value;
}
}),
toggleSidebar: () =>
set((state) => {
state.ui.sidebar.isOpen = !state.ui.sidebar.isOpen;
}),
// 他のアクションも同様に簡略化できる
}))
);
動的キーを持つストアの型付け
動的キー(インデックスシグネチャ)を持つストアの例:
typescriptimport { create } from 'zustand';
// 動的なエンティティコレクションの型
interface EntitiesState {
users: Record<string, User>;
posts: Record<string, Post>;
comments: Record<string, Comment>;
entities: {
[key: string]: Record<string, unknown>;
};
}
interface User {
id: string;
name: string;
email: string;
}
interface Post {
id: string;
title: string;
content: string;
authorId: string;
}
interface Comment {
id: string;
text: string;
postId: string;
authorId: string;
}
// アクション型
interface EntitiesActions {
addEntity: <T extends keyof EntitiesState>(
entityType: T,
entity: EntitiesState[T][string]
) => void;
updateEntity: <T extends keyof EntitiesState>(
entityType: T,
id: string,
updates: Partial<EntitiesState[T][string]>
) => void;
removeEntity: <T extends keyof EntitiesState>(
entityType: T,
id: string
) => void;
}
// 完全なストア型
type EntitiesStore = EntitiesState & EntitiesActions;
// ストアの作成
export const useEntitiesStore = create<EntitiesStore>(
(set) => ({
// 初期状態
users: {},
posts: {},
comments: {},
entities: {}, // 汎用エンティティストレージ
// アクション実装
addEntity: (entityType, entity) =>
set((state) => {
// 型安全な動的な更新
if (
entityType === 'users' ||
entityType === 'posts' ||
entityType === 'comments'
) {
return {
[entityType]: {
...state[entityType],
[entity.id]: entity,
},
};
}
// 汎用エンティティの場合
return {
entities: {
...state.entities,
[entityType]: {
...state.entities[entityType],
[(entity as any).id]: entity,
},
},
};
}),
updateEntity: (entityType, id, updates) =>
set((state) => {
if (
entityType === 'users' ||
entityType === 'posts' ||
entityType === 'comments'
) {
const currentEntity = state[entityType][id];
if (!currentEntity) return {};
return {
[entityType]: {
...state[entityType],
[id]: {
...currentEntity,
...updates,
},
},
};
}
// 汎用エンティティの場合
const entityCollection = state.entities[entityType];
if (!entityCollection) return {};
const currentEntity = entityCollection[id];
if (!currentEntity) return {};
return {
entities: {
...state.entities,
[entityType]: {
...entityCollection,
[id]: {
...currentEntity,
...updates,
},
},
},
};
}),
removeEntity: (entityType, id) =>
set((state) => {
if (
entityType === 'users' ||
entityType === 'posts' ||
entityType === 'comments'
) {
const newCollection = { ...state[entityType] };
delete newCollection[id];
return {
[entityType]: newCollection,
};
}
// 汎用エンティティの場合
const entityCollection = state.entities[entityType];
if (!entityCollection) return {};
const newCollection = { ...entityCollection };
delete newCollection[id];
return {
entities: {
...state.entities,
[entityType]: newCollection,
},
};
}),
})
);
このような動的なキー構造を持つストアでは、conditional types を活用してさらに型安全性を高めることができます。
インポート・エクスポートパターンと型の集約
大規模なアプリケーションでは、型定義を整理して再利用性を高めるパターンが重要です:
typescript// types/user.ts
export interface User {
id: string;
name: string;
email: string;
}
export interface UserState {
currentUser: User | null;
isLoading: boolean;
error: string | null;
}
export interface UserActions {
login: (email: string, password: string) => Promise<void>;
logout: () => void;
updateProfile: (updates: Partial<User>) => Promise<void>;
}
export type UserSlice = UserState & UserActions;
// types/ui.ts
export interface UIState {
theme: 'light' | 'dark';
sidebarOpen: boolean;
}
export interface UIActions {
toggleTheme: () => void;
toggleSidebar: () => void;
}
export type UISlice = UIState & UIActions;
// types/index.ts - 型の集約
import { UserSlice } from './user';
import { UISlice } from './ui';
export * from './user';
export * from './ui';
// 全体のストア型
export type RootState = UserSlice & UISlice;
// store/userSlice.ts
import { StateCreator } from 'zustand';
import { RootState, UserSlice } from '../types';
export const createUserSlice: StateCreator<
RootState,
[],
[],
UserSlice
> = (set, get) => ({
// UserSliceの実装
currentUser: null,
isLoading: false,
error: null,
login: async (email, password) => {
set({ isLoading: true, error: null });
try {
const user = await apiLogin(email, password);
set({ currentUser: user, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
logout: () => {
set({ currentUser: null });
},
updateProfile: async (updates) => {
const { currentUser } = get();
if (!currentUser) return;
set({ isLoading: true, error: null });
try {
const updatedUser = await apiUpdateProfile(
currentUser.id,
updates
);
set({ currentUser: updatedUser, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
});
// store/uiSlice.ts
import { StateCreator } from 'zustand';
import { RootState, UISlice } from '../types';
export const createUISlice: StateCreator<
RootState,
[],
[],
UISlice
> = (set) => ({
// UISliceの実装
theme: 'light',
sidebarOpen: false,
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
toggleSidebar: () =>
set((state) => ({
sidebarOpen: !state.sidebarOpen,
})),
});
// store/index.ts - ストアの作成
import { create } from 'zustand';
import { RootState } from '../types';
import { createUserSlice } from './userSlice';
import { createUISlice } from './uiSlice';
export const useStore = create<RootState>()((...a) => ({
...createUserSlice(...a),
...createUISlice(...a),
}));
このパターンの利点:
- 論理的な単位で型と実装を分離
- 型定義の再利用性の向上
- ファイル間の依存関係の明確化
- チーム開発での分業がしやすい
- テストのしやすさ
まとめ
型定義パターンの使い分け
状況に応じた型定義パターンの使い分けをまとめると:
-
小規模アプリケーション:
- 単一ファイルで型とストアを定義
- シンプルな interface または type で十分
-
中規模アプリケーション:
- 複数のスライスに分割
- 型定義とストア実装を分離
- 関連する型を適切にグループ化
-
大規模アプリケーション:
- 専用の型定義ディレクトリ構造
- 型の再利用と共有
- ドメイン領域ごとに型を分割
- StateCreator を活用したスライスパターン
使用するパターンは、チームの規模、アプリケーションの複雑さ、メンテナンス要件に基づいて選択すべきです。
リファクタリングと型安全性の両立
型安全性を維持しながらリファクタリングを行うためのポイント:
-
段階的リファクタリング:
- 一度にすべてを変更せず、小さなステップで進める
- 型エラーを 1 つずつ解決
-
型の抽象化レベルの適切な選択:
- 過度に具体的または抽象的な型を避ける
- 変更の可能性に応じて抽象化レベルを調整
-
型のバージョニング:
- 大きな変更を行う際は、一時的に古い型と新しい型を共存させる
- 段階的に移行する戦略を取る
-
型テスト:
- 型レベルのテスト(TypeScript の
expectType
ユーティリティなど)を活用 - 型の互換性を検証
- 型レベルのテスト(TypeScript の
Zustand の型システムは柔軟性が高いため、段階的なリファクタリングを行いやすいという利点があります。