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 の概念が必要) |
| 向いているプロジェクト | 中小規模・スピード重視のプロジェクト | 大規模・厳格なパターンが必要なプロジェクト |
コンポーネントツリーをまたぐ状態が必要だが、Reduxの学習コストや定型文が重いと感じる場合にZustandは最適です。特にNext.js・Viteを使ったモダンなReactプロジェクトでは現在最もおすすめできる状態管理ライブラリです。
インストール
npm install zustand # Immerを使う場合(後述) npm install immer
Zustand v5以降はTypeScriptの型定義が本体に同梱されています。@types/zustandは不要です。
基本的なストアの作成
Zustandのストアはcreate()関数で作成します。TypeScriptではstateとactionsを一つのinterfaceにまとめるのがベストプラクティスです。
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コンポーネントでの使い方
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)が不要です。フックを呼び出すだけで任意のコンポーネントからストアにアクセスできます。
実務的なストア設計:ユーザー管理の例
実際のアプリに近いユーザー管理ストアを例に、型設計のパターンを解説します。
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はデフォルトで、ストアの参照した値が変わったコンポーネントのみ再レンダリングします。セレクター関数を正しく書くことで、不要な再レンダリングを防げます。
// 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)でメモ化できます。// 派生状態を計算するセレクター 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ミドルウェアを使うとミュータブルな書き方でイミュータブルな更新が実現できます。
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 },
},
})),
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を使うと、状態の変化をブラウザで確認できます。
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に保存できます。
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を組み合わせる場合は、型が正しく推論されるよう適切な順序で適用する必要があります。
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" }
)
);
スライスパターンで大規模ストアを設計する
アプリが大きくなると、一つのストアに全ての状態を詰め込むと管理しにくくなります。スライスパターンは、ストアを機能ごとのスライスに分割してから統合する方法です。
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] })),
});
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 }),
});
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
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コンポーネントのテスト
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()を使えばフック外でもストアにアクセスできます。
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: set に存在しないプロパティを渡すとエラー
set({ nonExistentProp: "value" }); // Property 'nonExistentProp' does not exist
// OK: interfaceで定義したプロパティのみセット可能
set({ count: 0 }); // CounterStore["count"] は number 型
set({ count: "0" }); // 型エラー:string は number に代入不可
型エラー②:create<T>(fn)の古い書き方
// NG: v4以降では型推論が正しく効かない
const useStore = create<MyStore>((set) => ({...}));
// OK: create<T>()(...) のカリー化形式を使う
const useStore = create<MyStore>()((set) => ({...}));
型エラー③:ミドルウェアの型推論が壊れる
// ミドルウェアが多くなると型エラーが出ることがある
// 解決策: 明示的に StateCreator 型を指定する
import { StateCreator } from "zustand";
const mySlice: StateCreator<MyStore> = (set, get) => ({
// 型エラーを避けるために StateCreator に型を渡す
someAction: () => set({ count: 0 }),
});
型エラー④:persist後にhydrationが必要
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)完全ガイドも参考になります。

