概要
ここではZustandによる基本的な状態管理について書こうと思います。
- 良いストア設計・悪いストア設計
- カスタムフックとの使い分け
- TypeScriptでの型安全な実装
- パフォーマンス最適化
- Next.js App Routerでの考慮事項
Zustandとは
最小限の例
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
- ボイラープレートがない
-
フック設計 : 普通の
useStateのように使える - セレクタベース 必要な状態だけ購読可
- ミドルウェア対応
- 外部から読める
カスタムhookとの使い分け
原則:グローバル vs ローカル
グローバルな状態 → Zustand
ローカルな状態 → カスタムフック(useState)
具体例de使い分け
Zustandを使うべきケース
認証状態 - ページをまたいで必要
// store/authStore.ts
import { create } from 'zustand';
type AuthStore = {
user: User | null;
token: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
};
export const useAuthStore = create<AuthStore>((set) => ({
user: null,
token: null,
login: async (email, password) => {
const { user, token } = await api.login(email, password);
set({ user, token });
},
logout: () => {
set({ user: null, token: null });
},
}));
- 全ページで認証状態が必要
- ログイン状態は永続的
カスタムhookを使うべきケース
モーダルの開閉
// hooks/useModal.ts
export function useModal() {
const [isOpen, setIsOpen] = useState(false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
const toggle = () => setIsOpen(prev => !prev);
return { isOpen, open, close, toggle };
}
- 1つのコンポーネントツリー内で完結
- ページをまたがない
ハイブリッド
API取得はZustand、UI状態はローカル
// グローバルなデータキャッシュ
const useProductStore = create((set) => ({
products: [],
fetchProducts: async () => {
const products = await api.getProducts();
set({ products });
},
}));
// ローカルなUI状態
function ProductList() {
const products = useProductStore((state) => state.products);
// フィルタ状態はローカル
const [filter, setFilter] = useState('');
const [sortOrder, setSortOrder] = useState('asc');
const filtered = products.filter(p => p.name.includes(filter));
return (
<>
<input value={filter} onChange={e => setFilter(e.target.value)} />
{filtered.map(p => <ProductItem key={p.id} product={p} />)}
</>
);
}
## 型安全な設計
基本的な型定義
type CounterStore = {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
};
const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
Stateとアクションを分離
type State = {
user: User | null;
products: Product[];
loading: boolean;
};
type Actions = {
setUser: (user: User | null) => void;
fetchProducts: () => Promise<void>;
clearProducts: () => void;
};
type Store = State & Actions;
const useStore = create<Store>((set, get) => ({
// State
user: null,
products: [],
loading: false,
// Actions
setUser: (user) => set({ user }),
fetchProducts: async () => {
set({ loading: true });
try {
const products = await api.getProducts();
set({ products });
} finally {
set({ loading: false });
}
},
clearProducts: () => set({ products: [] }),
}));
- 状態とアクションの責務が明確
- 型補完が効く
- めメンテ容易
Immerを使った不変更新
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
type Todo = {
id: string;
text: string;
completed: boolean;
};
type TodoStore = {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => void;
};
const useTodoStore = create<TodoStore>()(
immer((set) => ({
todos: [],
addTodo: (text) => set((state) => {
state.todos.push({ id: crypto.randomUUID(), text, completed: false });
}),
toggleTodo: (id) => set((state) => {
const todo = state.todos.find(t => t.id === id);
if (todo) todo.completed = !todo.completed;
}),
removeTodo: (id) => set((state) => {
state.todos = state.todos.filter(t => t.id !== id);
}),
}))
);
- 直接的な書き方ができる(
push,todo.completed = !todo.completed)
## 設計基準
1つのストアに詰め込みすぎない
Godストア
const useStore = create((set) => ({
// 認証
user: null,
login: () => {},
logout: () => {},
// 商品
products: [],
fetchProducts: () => {},
// カート
cart: [],
addToCart: () => {},
// UI状態
theme: 'light',
sidebarOpen: false,
// 通知
notifications: [],
// めっちゃ長い
}));
ドメインごとに分割
// store/authStore.ts
export const useAuthStore = create((set) => ({
user: null,
login: async (email, password) => { /* ... */ },
logout: () => set({ user: null }),
}));
// store/productStore.ts
export const useProductStore = create((set) => ({
products: [],
fetchProducts: async () => { /* ... */ },
}));
// store/cartStore.ts
export const useCartStore = create((set) => ({
items: [],
addItem: (product) => { /* ... */ },
removeItem: (id) => { /* ... */ },
}));
ストア分割の基準
同じタイミングで変更される状態 → 同じストア
異なるタイミングで変更される状態 → 別のストア
セレクタは最小限
全体を取得
function ProductList() {
const store = useProductStore(); // ストア全体を購読
return (
<div>
{store.products.map(p => <div>{p.name}</div>)}
</div>
);
}
必要な部分だけ取得
function ProductList() {
const products = useProductStore((state) => state.products);
return (
<div>
{products.map(p => <div>{p.name}</div>)}
</div>
);
}
計算済み値はセレクタ
コンポーネントで計算
function Cart() {
const items = useCartStore((state) => state.items);
// 毎回計算される
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return <div>Total: ${total}</div>;
}
セレクタで計算
const selectTotal = (state) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
function Cart() {
const total = useCartStore(selectTotal);
return <div>Total: ${total}</div>;
}
メモ化されたセレクタ
import { createSelector } from 'reselect';
const selectItems = (state) => state.items;
const selectTotal = createSelector(
[selectItems],
(items) => items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
function Cart() {
const total = useCartStore(selectTotal);
return <div>Total: ${total}</div>;
}
アクションは純粋関数に近づける
副作用が多い
const useStore = create((set) => ({
products: [],
fetchProducts: async () => {
const products = await api.getProducts();
set({ products });
// 副作用が混ざっている
toast.success('Products loaded!');
localStorage.setItem('lastFetch', Date.now());
gtag('event', 'products_loaded');
},
}));
副作用はコンポーネント側で
const useStore = create((set) => ({
products: [],
fetchProducts: async () => {
const products = await api.getProducts();
set({ products });
return products; // 結果を返す
},
}));
function ProductPage() {
const fetchProducts = useStore((state) => state.fetchProducts);
useEffect(() => {
fetchProducts().then(() => {
// 副作用はここで
toast.success('Products loaded!');
gtag('event', 'products_loaded');
});
}, [fetchProducts]);
}
テスト戦略
ストア単体のテスト
import { renderHook, act } from '@testing-library/react';
import { useCounterStore } from './counterStore';
describe('counterStore', () => {
beforeEach(() => {
// テスト前にリセット
useCounterStore.setState({ count: 0 });
});
it('should increment count', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('should reset count', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(0);
});
});
コンポーネントテストでのモック
import { render, screen } from '@testing-library/react';
import { useAuthStore } from './authStore';
import UserProfile from './UserProfile';
// モック
jest.mock('./authStore');
describe('UserProfile', () => {
it('should show user name', () => {
(useAuthStore as jest.Mock).mockReturnValue({
user: { name: 'John' },
});
render(<UserProfile />);
expect(screen.getByText('John')).toBeInTheDocument();
});
});
まとめ
Zustandを適切に設計することで、リファクタリングしやすい状態管理の書き方ができるようになると思います。