はじめに
この記事では、React / TypeScript 環境で Zustand を使った状態管理の実装方法を記載します。
Zustand とは
Zustand は、React アプリケーションのための軽量で高速な状態管理ライブラリです。
Zustand はドイツ語で「状態」を意味し、Redux にインスパイアされながらも、よりシンプルで使いやすい API を提供しています。以下のような特徴があります。
- Redux のようなボイラープレートコードが不要
- Provider でラップする必要がない
- React Hooks ベースのシンプルな API
- TypeScript による完全な型安全性
- 最小限のバンドルサイズ(約 1KB)
- 不要な再レンダリングを防ぐ自動的な最適化
- Redux DevTools との統合が可能
- ミドルウェアによる拡張性
開発環境
開発環境は以下の通りです。
- Windows 11
- React 19.2.0
- TypeScript 5.9.3
- Vite 7.2.4
- Node.js 24.11.1
- npm 11.6.4
- zustand 5.0.2
インストール
まずは以下のコマンドでインストールします。
npm install zustand
基本的な利用方法
ストアの作成
Zustand の基本単位はストアです。ストアに状態とアクションを定義します。
src/stores ディレクトリを作成し、counterStore.ts ファイルを作成します。
import { create } from "zustand";
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
ストアは create 関数で作成します。状態と、それを更新するアクションを定義します。set 関数を使って状態を更新します。
ストアの利用
作成したストアは、カスタム Hook として使用できます。
import { useCounterStore } from "./stores/counterStore";
function App() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
const decrement = useCounterStore((state) => state.decrement);
const reset = useCounterStore((state) => state.reset);
return (
<div>
<h1>カウント: {count}</h1>
<button onClick={increment}>増やす</button>
<button onClick={decrement}>減らす</button>
<button onClick={reset}>リセット</button>
</div>
);
}
export default App;
セレクター関数を使って、必要な状態やアクションだけを取り出します。これにより、不要な再レンダリングを防ぐことができます。
Provider でラップする必要がないため、すぐに使い始められます。
複数の値を取得する方法
複数の値を一度に取得したい場合は、以下のような方法があります。
分割代入を使う方法
import { useCounterStore } from "../stores/counterStore";
export function Counter() {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div>
<h1>カウント: {count}</h1>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>リセット</button>
</div>
);
}
この方法は簡潔ですが、ストア内のすべての状態変更で再レンダリングされる点に注意が必要です。
セレクターで複数の値を返す方法
import { useCounterStore } from "../stores/counterStore";
export function Counter() {
const { count, increment } = useCounterStore((state) => ({
count: state.count,
increment: state.increment,
}));
return (
<div>
<h1>カウント: {count}</h1>
<button onClick={increment}>+1</button>
</div>
);
}
デフォルトでは、オブジェクトを返す場合は厳密等価性(===)で比較されるため、毎回新しいオブジェクトが生成されると再レンダリングされます。最適化するには useShallow を使用します。
useShallow を使った最適化
import { useCounterStore } from "../stores/counterStore";
import { shallow } from "zustand/shallow";
export function Counter() {
const { count, increment } = useCounterStore(
(state) => ({
count: state.count,
increment: state.increment,
}),
shallow
);
return (
<div>
<h1>カウント: {count}</h1>
<button onClick={increment}>+1</button>
</div>
);
}
useShallow を使うことで、オブジェクトの各プロパティを浅く比較し、実際に変更があった場合のみ再レンダリングされます。
算出値(Computed Values)
ストア内で算出値を定義できます。
import { create } from "zustand";
export interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
// 算出値をゲッター関数として定義
completedCount: () => number;
uncompletedCount: () => number;
allCompleted: () => boolean;
}
export const useTodoStore = create<TodoState>((set, get) => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [
...state.todos,
{
id: Date.now(),
text,
completed: false,
},
],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
completedCount: () => get().todos.filter((t) => t.completed).length,
uncompletedCount: () => get().todos.filter((t) => !t.completed).length,
allCompleted: () => {
const todos = get().todos;
return todos.length > 0 && todos.every((t) => t.completed);
},
}));
get 関数を使って、ストアの現在の状態にアクセスできます。
非同期アクション
非同期処理を含むアクションも簡単に実装できます。
import { create } from "zustand";
interface User {
id: number;
name: string;
email: string;
}
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
fetchUser: (id: number) => Promise<void>;
}
export const useUserStore = create<UserState>((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id) => {
set({ loading: true, error: null });
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${id}`
);
if (!response.ok) {
throw new Error("ユーザーの取得に失敗しました");
}
const user = await response.json();
set({ user, loading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : "エラーが発生しました",
loading: false,
});
}
},
}));
import { useEffect, useState } from "react";
import { useUserStore } from "../stores/userStore";
export function UserProfile() {
const [userId, setUserId] = useState(1);
const user = useUserStore((state) => state.user);
const loading = useUserStore((state) => state.loading);
const error = useUserStore((state) => state.error);
const fetchUser = useUserStore((state) => state.fetchUser);
useEffect(() => {
fetchUser(userId);
}, [userId, fetchUser]);
if (loading) return <div>読み込み中...</div>;
if (error) return <div>エラー: {error}</div>;
if (!user) return <div>ユーザーが見つかりません</div>;
return (
<div>
<input
type="number"
value={userId}
onChange={(e) => setUserId(Number(e.target.value))}
min="1"
max="10"
/>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
</div>
);
}
persist ミドルウェアで永続化
persist ミドルウェアを使用すると、状態を localStorage や sessionStorage に永続化できます。
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
interface Settings {
theme: "light" | "dark";
language: string;
notifications: boolean;
}
interface SettingsState extends Settings {
setTheme: (theme: "light" | "dark") => void;
setLanguage: (language: string) => void;
toggleNotifications: () => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: "light",
language: "ja",
notifications: true,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
toggleNotifications: () =>
set((state) => ({ notifications: !state.notifications })),
}),
{
name: "app-settings", // localStorage のキー名
}
)
);
import { useSettingsStore } from "../stores/settingsStore";
export function Settings() {
const theme = useSettingsStore((state) => state.theme);
const language = useSettingsStore((state) => state.language);
const notifications = useSettingsStore((state) => state.notifications);
const setTheme = useSettingsStore((state) => state.setTheme);
const setLanguage = useSettingsStore((state) => state.setLanguage);
const toggleNotifications = useSettingsStore(
(state) => state.toggleNotifications
);
return (
<div>
<div>
<label>テーマ: </label>
<select
value={theme}
onChange={(e) => setTheme(e.target.value as "light" | "dark")}
>
<option value="light">ライト</option>
<option value="dark">ダーク</option>
</select>
</div>
<div>
<label>言語: </label>
<select value={language} onChange={(e) => setLanguage(e.target.value)}>
<option value="ja">日本語</option>
<option value="en">English</option>
</select>
</div>
<div>
<label>
<input
type="checkbox"
checked={notifications}
onChange={toggleNotifications}
/>
通知を有効にする
</label>
</div>
</div>
);
}
sessionStorage を使用する場合
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
interface SessionState {
sessionData: string;
setSessionData: (data: string) => void;
}
export const useSessionStore = create<SessionState>()(
persist(
(set) => ({
sessionData: "",
setSessionData: (data) => set({ sessionData: data }),
}),
{
name: "session-storage",
storage: createJSONStorage(() => sessionStorage),
}
)
);
immer ミドルウェアで簡潔な更新
immer ミドルウェアを使用すると、イミュータブルな更新をより簡潔に書けます。
まず immer をインストールします。
npm install immer
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
interface User {
id: number;
name: string;
profile: {
age: number;
email: string;
address: {
city: string;
country: string;
};
};
}
interface UserState {
user: User | null;
setUser: (user: User) => void;
updateEmail: (email: string) => void;
updateCity: (city: string) => void;
}
export const useUserStore = create<UserState>()(
immer((set) => ({
user: null,
setUser: (user) => set({ user }),
updateEmail: (email) =>
set((state) => {
if (state.user) {
state.user.profile.email = email;
}
}),
updateCity: (city) =>
set((state) => {
if (state.user) {
state.user.profile.address.city = city;
}
}),
}))
);
immer を使うことで、ネストしたオブジェクトの更新が直感的に書けます。Immer が内部でイミュータブルな更新に変換してくれます。
devtools ミドルウェアでデバッグ
devtools ミドルウェアを使用すると、Redux DevTools でストアの状態を確認できます。
import { create } from "zustand";
import { devtools } from "zustand/middleware";
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterState>()(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }), false, "increment"),
decrement: () => set((state) => ({ count: state.count - 1 }), false, "decrement"),
reset: () => set({ count: 0 }, false, "reset"),
}),
{
name: "CounterStore",
}
)
);
set 関数の第3引数にアクション名を指定すると、Redux DevTools でアクションの履歴が確認できます。
複数のミドルウェアを組み合わせる
複数のミドルウェアを組み合わせて使用できます。
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import { devtools } from "zustand/middleware";
export interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
deleteTodo: (id: number) => void;
}
export const useTodoStore = create<TodoState>()(
devtools(
persist(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
state.todos.push({
id: Date.now(),
text,
completed: false,
});
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}),
deleteTodo: (id) =>
set((state) => {
const index = state.todos.findIndex((t) => t.id === id);
if (index !== -1) {
state.todos.splice(index, 1);
}
}),
})),
{
name: "todo-storage",
}
),
{
name: "TodoStore",
}
)
);
ミドルウェアは内側から外側に適用されます。この例では、immer → persist → devtools の順に適用されます。
コンポーネント外でストアを使用
コンポーネント外(ユーティリティ関数など)でストアを使用する場合は、getState() や setState() を使用します。
import { create } from "zustand";
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
import { useCounterStore } from "../stores/counterStore";
export function logCurrentCount() {
const count = useCounterStore.getState().count;
console.log("現在のカウント:", count);
}
export function incrementOutside() {
useCounterStore.getState().increment();
}
export function setCountDirectly(count: number) {
useCounterStore.setState({ count });
}
ストアのサブスクライブ
ストアの変更を監視するには subscribe を使用します。
import { useCounterStore } from "../stores/counterStore";
// ストアの変更を監視
const unsubscribe = useCounterStore.subscribe((state, prevState) => {
console.log("カウントが変更されました:", prevState.count, "→", state.count);
});
// 監視を解除
// unsubscribe();
ストアの分割
大規模なアプリケーションでは、ストアを複数のスライスに分割すると管理しやすくなります。
import { StateCreator } from "zustand";
export interface CounterSlice {
count: number;
increment: () => void;
decrement: () => void;
}
export const createCounterSlice: StateCreator<CounterSlice> = (set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
});
import { StateCreator } from "zustand";
export interface UserSlice {
username: string;
setUsername: (username: string) => void;
}
export const createUserSlice: StateCreator<UserSlice> = (set) => ({
username: "",
setUsername: (username) => set({ username }),
});
import { create } from "zustand";
import { CounterSlice, createCounterSlice } from "./slices/counterSlice";
import { UserSlice, createUserSlice } from "./slices/userSlice";
type StoreState = CounterSlice & UserSlice;
export const useStore = create<StoreState>()((...a) => ({
...createCounterSlice(...a),
...createUserSlice(...a),
}));
まとめ
Zustand を使うことで、React / TypeScript アプリケーションにシンプルで強力な状態管理を導入できます。
主なポイントは以下の通りです。
- Redux のような複雑な設定が不要なシンプルな API
- Provider なしで使用可能
- セレクターによる不要な再レンダリングの防止
- TypeScript による完全な型安全性
- 非同期処理のネイティブサポート
- persist ミドルウェアによる簡単な永続化
- immer ミドルウェアによる簡潔な状態更新
- Redux DevTools との統合によるデバッグ
- 最小限のバンドルサイズ(約 1KB)
Zustand は小規模なプロジェクトから大規模なエンタープライズアプリケーションまで、柔軟にスケールできる状態管理ライブラリです。Redux のようなボイラープレートは不要で、Context API の複雑さも避けられます。学習コストが低く、すぐに実践で使える点が大きな魅力です。
参考
関連記事
