Zustand と React Context はどう違う?使い分けの具体例付き解説

React 開発において、ステート管理は避けて通れない重要な課題です。コンポーネント間でのデータ共有や状態の同期は、アプリケーションの規模が大きくなるにつれて複雑になってまいります。
この課題に対する解決策として、React Context API と Zustand という二つの選択肢があります。「どちらを使えばいいの?」「それぞれの特徴は何?」「プロジェクトの規模によって使い分けるべき?」といった疑問を抱えている開発者の方も多いのではないでしょうか。
今回は、React Context と Zustand の根本的な違いから実装方法まで、具体的なコード例とともに詳しく解説してまいります。それぞれの特性を理解し、プロジェクトに最適な選択ができるようになっていただければと思います。
背景
React Context の登場と普及
React Context API は、React 16.3 で正式に導入され、コンポーネント間でのデータ共有を革新的に簡素化しました。それまでは、深くネストされたコンポーネントにデータを渡すために「props drilling」と呼ばれる問題に悩まされていたのです。
Context API の登場により、以下のような従来の課題が解決されました。
# | 課題 | Context API による解決 |
---|---|---|
1 | Props drilling | プロバイダー経由でのダイレクトアクセス |
2 | 中間コンポーネントの責任 | 不要な props の受け渡しを回避 |
3 | グローバル状態管理 | Redux などの重いライブラリに頼らない軽量な解決策 |
4 | 型安全性 | TypeScript との優れた統合 |
typescript// Context API登場前の典型的な問題
// App.tsx
function App() {
const user = { name: 'John', role: 'admin' };
return <HomePage user={user} />;
}
// HomePage.tsx
function HomePage({ user }: { user: User }) {
return (
<div>
<Header user={user} />
<MainContent user={user} />
</div>
);
}
// Header.tsx
function Header({ user }: { user: User }) {
return (
<nav>
<UserMenu user={user} /> {/* さらに深くpropsを渡す */}
</nav>
);
}
typescript// Context API による解決
// UserContext.tsx
import React, { createContext, useContext } from 'react';
interface User {
name: string;
role: 'admin' | 'user';
}
const UserContext = createContext<User | null>(null);
export function UserProvider({
children,
user,
}: {
children: React.ReactNode;
user: User;
}) {
return (
<UserContext.Provider value={user}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const context = useContext(UserContext);
if (context === null) {
throw new Error(
'useUser must be used within a UserProvider'
);
}
return context;
}
Context API の普及により、Redux のような重厚なライブラリを使わずとも、効率的なステート管理が可能になりました。特に小〜中規模のアプリケーションでは、Context API だけで十分なケースも多く見られるようになったのです。
Zustand の登場背景
Zustand は 2019 年に登場した比較的新しいステート管理ライブラリです。「Zustand」はドイツ語で「状態」を意味し、その名前の通りシンプルで直感的な状態管理を目指しています。
Zustand が開発された背景には、既存のソリューションに対する以下のような課題認識がありました。
Redux の複雑性への対応:
- 大量のボイラープレート
- アクション、リデューサー、ディスパッチャーの概念習得コスト
- 非同期処理での複雑な設定
React Context の制約への対応:
- パフォーマンスの問題(不要な再レンダリング)
- Provider の階層構造の複雑化
- 複数のコンテキストの管理の煩雑さ
typescript// Zustandの基本的な思想を表す例
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 })),
})
);
// 使用方法
function Counter() {
const { count, increment, decrement } = useCounterStore();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
Zustand の特徴的な設計思想は以下の通りです。
シンプルさの追求:
- Provider 不要のアーキテクチャ
- 最小限の API 設計
- 学習コストの大幅な削減
パフォーマンスファースト:
- 細粒度の再レンダリング制御
- メモリ効率の最適化
- バンドルサイズの最小化
実用性重視:
- TypeScript との優れた統合
- ミドルウェアによる拡張性
- DevTools の充実
両者が解決しようとする問題の違い
React Context と Zustand は、どちらもステート管理の課題を解決しますが、解決へのアプローチが根本的に異なります。
React Context のアプローチ:
React Context は、React の標準機能として「コンポーネントツリーでのデータ共有」という問題を解決します。React の哲学に沿った設計で、以下の特徴があります。
typescript// Context は依存注入パターンを採用
// ThemeContext.tsx
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = createContext<
ThemeContextType | undefined
>(undefined);
export function ThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
const [theme, setTheme] = useState<'light' | 'dark'>(
'light'
);
const toggleTheme = useCallback(() => {
setTheme((prev) =>
prev === 'light' ? 'dark' : 'light'
);
}, []);
const value = useMemo(
() => ({ theme, toggleTheme }),
[theme, toggleTheme]
);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
Zustand のアプローチ:
Zustand は「グローバルステートストア」として設計され、React に依存しない独立したステート管理を提供します。
typescript// Zustand はストアパターンを採用
// theme-store.ts
interface ThemeState {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
export const useThemeStore = create<ThemeState>((set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
}));
この根本的な違いから、以下のような特性の差が生まれます。
# | 観点 | React Context | Zustand |
---|---|---|---|
1 | 設計思想 | React エコシステム内での解決 | フレームワーク非依存の状態管理 |
2 | データの流れ | コンポーネントツリーに依存 | グローバルアクセス可能 |
3 | 責任の範囲 | UI コンポーネントとの密結合 | ビジネスロジックの分離 |
4 | 拡張性 | React の機能に制約される | ミドルウェアによる柔軟な拡張 |
5 | テスタビリティ | コンポーネントテストに依存 | 独立したユニットテストが容易 |
課題
Context の re-render 問題
React Context の最も深刻な課題の一つが、不要な再レンダリングの発生です。Context の値が変更されると、そのコンテキストを使用するすべてのコンポーネントが再レンダリングされてしまいます。
この問題を具体的に見てみましょう。
typescript// 問題のあるContext実装
// AppContext.tsx
interface AppState {
user: User | null;
theme: 'light' | 'dark';
notifications: Notification[];
setUser: (user: User | null) => void;
setTheme: (theme: 'light' | 'dark') => void;
addNotification: (notification: Notification) => void;
}
export function AppProvider({
children,
}: {
children: React.ReactNode;
}) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<'light' | 'dark'>(
'light'
);
const [notifications, setNotifications] = useState<
Notification[]
>([]);
const addNotification = useCallback(
(notification: Notification) => {
setNotifications((prev) => [...prev, notification]);
},
[]
);
// ここが問題:いずれかの状態が変わると全体が再レンダリング
const value: AppState = {
user,
theme,
notifications,
setUser,
setTheme,
addNotification,
};
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
typescript// このコンポーネントは、通知が追加されるたびに再レンダリングされる
// UserProfile.tsx
function UserProfile() {
const { user } = useContext(AppContext);
console.log('UserProfile re-rendered'); // 不要な再レンダリングを確認
if (!user) return <div>ログインしてください</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
この問題を回避するための従来の解決策は複雑になりがちです。
typescript// Context分割によるパフォーマンス最適化の試み
// UserContext.tsx
const UserContext = createContext<{
user: User | null;
setUser: (user: User | null) => void;
} | null>(null);
// ThemeContext.tsx
const ThemeContext = createContext<{
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
} | null>(null);
// NotificationContext.tsx
const NotificationContext = createContext<{
notifications: Notification[];
addNotification: (notification: Notification) => void;
} | null>(null);
// 結果として複数のProvider が必要になる
function App() {
return (
<UserProvider>
<ThemeProvider>
<NotificationProvider>
<MainApp />
</NotificationProvider>
</ThemeProvider>
</UserProvider>
);
}
Provider 地獄の課題
複数の Context を使用する際に発生する「Provider Hell」(Provider 地獄)は、React Context API の深刻な課題です。アプリケーションが成長するにつれて、この問題はより顕著になります。
typescript// Provider地獄の典型例
// App.tsx
function App() {
return (
<QueryClient>
<AuthProvider>
<ThemeProvider>
<LanguageProvider>
<NotificationProvider>
<CartProvider>
<UserPreferencesProvider>
<Router>
<Routes>
<Route
path='/'
element={<HomePage />}
/>
</Routes>
</Router>
</UserPreferencesProvider>
</CartProvider>
</NotificationProvider>
</LanguageProvider>
</ThemeProvider>
</AuthProvider>
</QueryClient>
);
}
この構造は以下の問題を引き起こします。
可読性の低下:
- ネストの深さによる視認性の悪化
- 新しい Provider の追加位置の判断困難
- デバッグ時の構造把握の困難
保守性の問題:
- Provider 間の依存関係の管理
- 初期化順序の制約
- テスト時の複雑なセットアップ
typescript// テスト時の複雑なセットアップ
// UserProfile.test.tsx
function TestWrapper({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<ThemeProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</ThemeProvider>
</AuthProvider>
);
}
test('UserProfile displays user name', () => {
render(
<TestWrapper>
<UserProfile />
</TestWrapper>
);
// テストコード...
});
Zustand の学習コストと Context の複雑性
両者にはそれぞれ異なる学習曲線と複雑性があります。
React Context の複雑性:
Context API は React の標準機能であるため、習得しやすく見えますが、実際には以下の複雑さがあります。
typescript// Context の複雑な実装例
// DataContext.tsx
interface DataState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
interface DataActions<T> {
setData: (data: T) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
fetchData: () => Promise<void>;
}
function createDataContext<T>() {
const Context = createContext<
(DataState<T> & DataActions<T>) | null
>(null);
function DataProvider({
children,
fetchFn,
}: {
children: React.ReactNode;
fetchFn: () => Promise<T>;
}) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await fetchFn();
setData(result);
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Unknown error'
);
} finally {
setLoading(false);
}
}, [fetchFn]);
const value = useMemo(
() => ({
data,
loading,
error,
setData,
setLoading,
setError,
fetchData,
}),
[data, loading, error, fetchData]
);
return (
<Context.Provider value={value}>
{children}
</Context.Provider>
);
}
function useData() {
const context = useContext(Context);
if (!context) {
throw new Error(
'useData must be used within a DataProvider'
);
}
return context;
}
return { DataProvider, useData };
}
Zustand の学習コスト:
一方、Zustand は新しいライブラリであるため、概念の習得が必要ですが、API がシンプルで学習コストは低めです。
typescript// Zustand の同等実装(はるかにシンプル)
// data-store.ts
import { create } from 'zustand';
interface DataState<T> {
data: T | null;
loading: boolean;
error: string | null;
fetchData: (fetchFn: () => Promise<T>) => Promise<void>;
setData: (data: T | null) => void;
clearError: () => void;
}
export function createDataStore<T>() {
return create<DataState<T>>((set) => ({
data: null,
loading: false,
error: null,
fetchData: async (fetchFn: () => Promise<T>) => {
set({ loading: true, error: null });
try {
const result = await fetchFn();
set({ data: result, loading: false });
} catch (error) {
set({
error:
error instanceof Error
? error.message
: 'Unknown error',
loading: false,
});
}
},
setData: (data) => set({ data }),
clearError: () => set({ error: null }),
}));
}
// 使用例
const useUserStore = createDataStore<User>();
この比較から分かるように、Zustand は少ないコードで同等の機能を実現できるため、長期的には学習・保守コストが低くなる傾向があります。
解決策
基本アーキテクチャの違い
React Context と Zustand の根本的な違いは、そのアーキテクチャ設計にあります。この違いを理解することで、適切な選択ができるようになるでしょう。
React Context のアーキテクチャ:
Context API は React のコンポーネントツリーに依存した設計で、以下の特徴があります。
typescript// Context-based アーキテクチャ
// 1. Context定義
const AppContext = createContext<AppState | null>(null);
// 2. Provider(データの注入点)
function AppProvider({
children,
}: {
children: React.ReactNode;
}) {
const [state, setState] = useState(initialState);
return (
<AppContext.Provider value={{ state, setState }}>
{children}
</AppContext.Provider>
);
}
// 3. Consumer(データの消費点)
function Component() {
const context = useContext(AppContext);
if (!context) throw new Error('Context not found');
return <div>{context.state.value}</div>;
}
// 4. アプリケーション構造(依存注入パターン)
function App() {
return (
<AppProvider>
<Component />
</AppProvider>
);
}
Zustand のアーキテクチャ:
Zustand は独立したストアベースの設計で、React に依存しない構造を持ちます。
typescript// Store-based アーキテクチャ
// 1. Store定義(自己完結型)
interface AppState {
value: string;
setValue: (value: string) => void;
reset: () => void;
}
const useAppStore = create<AppState>((set) => ({
value: '',
setValue: (value) => set({ value }),
reset: () => set({ value: '' }),
}));
// 2. 直接使用(Provider不要)
function Component() {
const { value, setValue } = useAppStore();
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
// 3. アプリケーション構造(シンプル)
function App() {
return <Component />; // Provider不要
}
この違いにより、以下のような特性の差が生まれます。
# | 特徴 | React Context | Zustand |
---|---|---|---|
1 | データの所在 | Provider コンポーネント内 | 独立したストア |
2 | アクセス方法 | useContext + Provider の組み合わせ | 直接的なフック呼び出し |
3 | 初期化タイミング | Provider のマウント時 | ストア作成時 |
4 | スコープ | Provider の子コンポーネント | グローバルアクセス可能 |
5 | テスタビリティ | モック Provider が必要 | ストア単体でテスト可能 |
API 設計思想の比較
両者の API 設計思想は、開発者体験に大きな影響を与えます。
React Context の宣言的アプローチ:
typescript// 宣言的で明示的な設計
// UserContext.tsx
interface UserContextType {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
updateProfile: (updates: Partial<User>) => Promise<void>;
}
export function UserProvider({
children,
}: {
children: React.ReactNode;
}) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const login = useCallback(
async (credentials: Credentials) => {
setLoading(true);
try {
const userData = await authAPI.login(credentials);
setUser(userData);
} finally {
setLoading(false);
}
},
[]
);
const logout = useCallback(() => {
authAPI.logout();
setUser(null);
}, []);
const updateProfile = useCallback(
async (updates: Partial<User>) => {
if (!user) return;
const updatedUser = await userAPI.update(
user.id,
updates
);
setUser(updatedUser);
},
[user]
);
const value = useMemo(
() => ({
user,
login,
logout,
updateProfile,
}),
[user, login, logout, updateProfile]
);
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error(
'useUser must be used within a UserProvider'
);
}
return context;
}
Zustand の関数型アプローチ:
typescript// 関数型で簡潔な設計
// user-store.ts
interface UserState {
user: User | null;
loading: boolean;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
updateProfile: (updates: Partial<User>) => Promise<void>;
}
export const useUserStore = create<UserState>(
(set, get) => ({
user: null,
loading: false,
login: async (credentials) => {
set({ loading: true });
try {
const response = await authAPI.login(credentials);
set({ user: response.user, loading: false });
} catch (error) {
set({ loading: false });
throw error;
}
},
logout: () => {
authAPI.logout();
set({ user: null });
},
updateProfile: async (updates) => {
const { user } = get();
if (!user) return;
const updatedUser = await userAPI.update(
user.id,
updates
);
set({ user: updatedUser });
},
})
);
この設計思想の違いから、以下のような開発体験の差が生まれます。
Context の特徴:
- React パターンに馴染みやすい
- エラーハンドリングが明示的
- Provider の依存関係が明確
- テストでの境界が明確
Zustand の特徴:
- ボイラープレートが少ない
- ビジネスロジックに集中できる
- 関数型プログラミングの利点
- モジュラーな設計が容易
パフォーマンス特性の違い
パフォーマンス面での両者の違いは、アプリケーションの規模が大きくなるにつれて重要になります。
React Context のパフォーマンス特性:
typescript// Context での再レンダリング測定例
// PerformanceTestContext.tsx
interface CounterContextType {
count: number;
name: string;
increment: () => void;
updateName: (name: string) => void;
}
function CounterProvider({
children,
}: {
children: React.ReactNode;
}) {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
console.log('CounterProvider re-rendered'); // プロバイダーの再レンダリング
const value = {
count,
name,
increment: () => setCount((c) => c + 1),
updateName: setName,
};
return (
<CounterContext.Provider value={value}>
{children}
</CounterContext.Provider>
);
}
// countのみ使用するコンポーネント
function CountDisplay() {
const { count } = useContext(CounterContext);
console.log('CountDisplay re-rendered'); // nameが変更されても再レンダリング
return <div>Count: {count}</div>;
}
// nameのみ使用するコンポーネント
function NameDisplay() {
const { name } = useContext(CounterContext);
console.log('NameDisplay re-rendered'); // countが変更されても再レンダリング
return <div>Name: {name}</div>;
}
Zustand のパフォーマンス特性:
typescript// Zustand での選択的な再レンダリング
// performance-store.ts
interface CounterState {
count: number;
name: string;
increment: () => void;
updateName: (name: string) => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
name: '',
increment: () =>
set((state) => ({ count: state.count + 1 })),
updateName: (name) => set({ name }),
}));
// countのみ購読(nameの変更では再レンダリングされない)
function CountDisplay() {
const count = useCounterStore((state) => state.count);
console.log('CountDisplay re-rendered'); // countの変更時のみ
return <div>Count: {count}</div>;
}
// nameのみ購読(countの変更では再レンダリングされない)
function NameDisplay() {
const name = useCounterStore((state) => state.name);
console.log('NameDisplay re-rendered'); // nameの変更時のみ
return <div>Name: {name}</div>;
}
// 複数の値を効率的に購読
function BothDisplay() {
const { count, name } = useCounterStore(
(state) => ({ count: state.count, name: state.name }),
shallow // 浅い比較で不要な再レンダリングを防ぐ
);
return (
<div>
Count: {count}, Name: {name}
</div>
);
}
パフォーマンス比較結果:
# | 測定項目 | React Context | Zustand |
---|---|---|---|
1 | 初期化時間 | 標準 | 高速 |
2 | 状態更新時の再レンダリング | 全体的 | 選択的 |
3 | メモリ使用量 | 中程度 | 低い |
4 | バンドルサイズ | 0KB(標準) | 2.9KB |
5 | 複雑な状態での性能 | 低下傾向 | 安定 |
開発体験の比較
開発者の生産性と保守性の観点から、両者の違いを比較してみましょう。
デバッグ体験の違い:
typescript// React Context のデバッグ
// DevTools での表示が複雑
<App>
<UserProvider>
<ThemeProvider>
<NotificationProvider>
<Component />
</NotificationProvider>
</ThemeProvider>
</UserProvider>
</App>
typescript// Zustand のデバッグ
// 専用DevToolsで状態の変化を追跡
import { devtools } from 'zustand/middleware';
const useStore = create<State>()(
devtools(
(set) => ({
// state definition
}),
{
name: 'my-store', // DevToolsでの表示名
}
)
);
TypeScript 統合の違い:
typescript// Context での型安全性の確保
interface AppContextType {
user: User | null;
setUser: (user: User | null) => void;
}
const AppContext = createContext<
AppContextType | undefined
>(undefined);
function useApp(): AppContextType {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error(
'useApp must be used within an AppProvider'
);
}
return context;
}
typescript// Zustand での型安全性(より自然)
interface AppState {
user: User | null;
setUser: (user: User | null) => void;
}
const useAppStore = create<AppState>((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
// 使用時の型推論が自動で効く
const user = useAppStore((state) => state.user); // User | null と推論
const setUser = useAppStore((state) => state.setUser); // (user: User | null) => void と推論
具体例
テーマ管理での比較実装
実際のテーマ管理機能を両方の手法で実装し、違いを比較してみましょう。
React Context でのテーマ管理:
typescript// theme-context.tsx
import React, {
createContext,
useContext,
useEffect,
useState,
} from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
isDark: boolean;
}
const ThemeContext = createContext<
ThemeContextType | undefined
>(undefined);
export function ThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
const [theme, setTheme] = useState<Theme>('system');
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const savedTheme = localStorage.getItem(
'theme'
) as Theme;
if (
savedTheme &&
['light', 'dark', 'system'].includes(savedTheme)
) {
setTheme(savedTheme);
}
}, []);
useEffect(() => {
const updateTheme = () => {
let shouldBeDark = false;
if (theme === 'dark') {
shouldBeDark = true;
} else if (theme === 'system') {
shouldBeDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
}
setIsDark(shouldBeDark);
document.documentElement.classList.toggle(
'dark',
shouldBeDark
);
};
updateTheme();
if (theme === 'system') {
const mediaQuery = window.matchMedia(
'(prefers-color-scheme: dark)'
);
mediaQuery.addEventListener('change', updateTheme);
return () =>
mediaQuery.removeEventListener(
'change',
updateTheme
);
}
}, [theme]);
const handleSetTheme = (newTheme: Theme) => {
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
};
const value = {
theme,
setTheme: handleSetTheme,
isDark,
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error(
'useTheme must be used within a ThemeProvider'
);
}
return context;
}
// 使用例
function ThemeSelector() {
const { theme, setTheme, isDark } = useTheme();
return (
<div
className={`p-4 ${
isDark
? 'bg-gray-800 text-white'
: 'bg-white text-black'
}`}
>
<h3>テーマ設定</h3>
<select
value={theme}
onChange={(e) => setTheme(e.target.value as Theme)}
>
<option value='light'>ライト</option>
<option value='dark'>ダーク</option>
<option value='system'>システム</option>
</select>
<p>現在のテーマ: {isDark ? 'ダーク' : 'ライト'}</p>
</div>
);
}
Zustand でのテーマ管理:
typescript// theme-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'light' | 'dark' | 'system';
interface ThemeState {
theme: Theme;
isDark: boolean;
setTheme: (theme: Theme) => void;
initializeTheme: () => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'system',
isDark: false,
setTheme: (theme) => {
set({ theme });
get().updateDarkMode(theme);
},
updateDarkMode: (theme: Theme) => {
let shouldBeDark = false;
if (theme === 'dark') {
shouldBeDark = true;
} else if (theme === 'system') {
shouldBeDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
}
set({ isDark: shouldBeDark });
document.documentElement.classList.toggle(
'dark',
shouldBeDark
);
},
initializeTheme: () => {
const { theme, updateDarkMode } = get();
updateDarkMode(theme);
// システムテーマの変更を監視
if (theme === 'system') {
const mediaQuery = window.matchMedia(
'(prefers-color-scheme: dark)'
);
const handleChange = () =>
updateDarkMode('system');
mediaQuery.addEventListener(
'change',
handleChange
);
// クリーンアップは手動で管理する必要がある
return () =>
mediaQuery.removeEventListener(
'change',
handleChange
);
}
},
}),
{
name: 'theme-storage',
partialize: (state) => ({ theme: state.theme }),
}
)
);
// 使用例
function ThemeSelector() {
const { theme, isDark, setTheme } = useThemeStore();
return (
<div
className={`p-4 ${
isDark
? 'bg-gray-800 text-white'
: 'bg-white text-black'
}`}
>
<h3>テーマ設定</h3>
<select
value={theme}
onChange={(e) => setTheme(e.target.value as Theme)}
>
<option value='light'>ライト</option>
<option value='dark'>ダーク</option>
<option value='system'>システム</option>
</select>
<p>現在のテーマ: {isDark ? 'ダーク' : 'ライト'}</p>
</div>
);
}
// 初期化コンポーネント
function ThemeInitializer() {
const initializeTheme = useThemeStore(
(state) => state.initializeTheme
);
useEffect(() => {
const cleanup = initializeTheme();
return cleanup;
}, [initializeTheme]);
return null;
}
比較結果:
# | 項目 | React Context | Zustand |
---|---|---|---|
1 | コード行数 | 85 行 | 65 行 |
2 | 設定の複雑さ | 中程度 | シンプル |
3 | 永続化の実装 | 手動実装 | ミドルウェア |
4 | TypeScript 統合 | 手動設定 | 自動推論 |
5 | テスタビリティ | 複雑 | 簡単 |
ユーザー認証状態管理での比較
ユーザー認証という複雑な状態管理を両方の手法で実装してみましょう。
React Context での認証管理:
typescript// auth-context.tsx
interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'user';
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (
email: string,
password: string,
name: string
) => Promise<void>;
clearError: () => void;
}
const AuthContext = createContext<
AuthContextType | undefined
>(undefined);
export function AuthProvider({
children,
}: {
children: React.ReactNode;
}) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// 初期化時にトークンから認証状態を復元
const initAuth = async () => {
const token = localStorage.getItem('authToken');
if (token) {
try {
const userData = await validateToken(token);
setUser(userData);
} catch (err) {
localStorage.removeItem('authToken');
}
}
setIsLoading(false);
};
initAuth();
}, []);
const login = useCallback(
async (email: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const response = await authAPI.login({
email,
password,
});
localStorage.setItem('authToken', response.token);
setUser(response.user);
} catch (err) {
setError(
err instanceof Error
? err.message
: 'ログインに失敗しました'
);
} finally {
setIsLoading(false);
}
},
[]
);
const logout = useCallback(async () => {
setIsLoading(true);
try {
await authAPI.logout();
} finally {
localStorage.removeItem('authToken');
setUser(null);
setIsLoading(false);
}
}, []);
const register = useCallback(
async (
email: string,
password: string,
name: string
) => {
setIsLoading(true);
setError(null);
try {
const response = await authAPI.register({
email,
password,
name,
});
localStorage.setItem('authToken', response.token);
setUser(response.user);
} catch (err) {
setError(
err instanceof Error
? err.message
: '登録に失敗しました'
);
} finally {
setIsLoading(false);
}
},
[]
);
const clearError = useCallback(() => {
setError(null);
}, []);
const value = useMemo(
() => ({
user,
isLoading,
error,
login,
logout,
register,
clearError,
}),
[
user,
isLoading,
error,
login,
logout,
register,
clearError,
]
);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error(
'useAuth must be used within an AuthProvider'
);
}
return context;
}
Zustand での認証管理:
typescript// auth-store.ts
import { create } from 'zustand';
import {
persist,
subscribeWithSelector,
} from 'zustand/middleware';
interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'user';
}
interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
isInitialized: boolean;
// Actions
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (
email: string,
password: string,
name: string
) => Promise<void>;
clearError: () => void;
initializeAuth: () => Promise<void>;
}
export const useAuthStore = create<AuthState>()(
subscribeWithSelector(
persist(
(set, get) => ({
user: null,
isLoading: false,
error: null,
isInitialized: false,
login: async (email, password) => {
set({ isLoading: true, error: null });
try {
const response = await authAPI.login({
email,
password,
});
set({
user: response.user,
isLoading: false,
});
} catch (err) {
set({
error:
err instanceof Error
? err.message
: 'ログインに失敗しました',
isLoading: false,
});
}
},
logout: async () => {
set({ isLoading: true });
try {
await authAPI.logout();
} finally {
set({
user: null,
isLoading: false,
error: null,
});
}
},
register: async (email, password, name) => {
set({ isLoading: true, error: null });
try {
const response = await authAPI.register({
email,
password,
name,
});
set({
user: response.user,
isLoading: false,
});
} catch (err) {
set({
error:
err instanceof Error
? err.message
: '登録に失敗しました',
isLoading: false,
});
}
},
clearError: () => set({ error: null }),
initializeAuth: async () => {
if (get().isInitialized) return;
set({ isLoading: true });
try {
const token = localStorage.getItem('authToken');
if (token) {
const userData = await validateToken(token);
set({ user: userData });
}
} catch (err) {
localStorage.removeItem('authToken');
} finally {
set({
isLoading: false,
isInitialized: true,
});
}
},
}),
{
name: 'auth-storage',
partialize: (state) => ({ user: state.user }),
}
)
)
);
// トークンの自動保存
useAuthStore.subscribe(
(state) => state.user,
(user) => {
if (user) {
// ユーザーログイン時の処理
console.log('User logged in:', user);
} else {
// ユーザーログアウト時の処理
localStorage.removeItem('authToken');
console.log('User logged out');
}
}
);
フォーム状態管理での比較
複雑なフォームの状態管理における両者の実装を比較します。
React Context でのフォーム管理:
typescript// form-context.tsx
interface FormData {
personalInfo: {
firstName: string;
lastName: string;
email: string;
phone: string;
};
preferences: {
newsletter: boolean;
notifications: boolean;
theme: 'light' | 'dark';
};
address: {
street: string;
city: string;
zipCode: string;
country: string;
};
}
interface FormContextType {
formData: FormData;
errors: Record<string, string>;
isSubmitting: boolean;
updateField: (
section: keyof FormData,
field: string,
value: any
) => void;
validateForm: () => boolean;
submitForm: () => Promise<void>;
resetForm: () => void;
}
const initialFormData: FormData = {
personalInfo: {
firstName: '',
lastName: '',
email: '',
phone: '',
},
preferences: {
newsletter: false,
notifications: true,
theme: 'light',
},
address: {
street: '',
city: '',
zipCode: '',
country: '',
},
};
const FormContext = createContext<
FormContextType | undefined
>(undefined);
export function FormProvider({
children,
}: {
children: React.ReactNode;
}) {
const [formData, setFormData] =
useState<FormData>(initialFormData);
const [errors, setErrors] = useState<
Record<string, string>
>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const updateField = useCallback(
(
section: keyof FormData,
field: string,
value: any
) => {
setFormData((prev) => ({
...prev,
[section]: {
...prev[section],
[field]: value,
},
}));
// エラーをクリア
if (errors[`${section}.${field}`]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[`${section}.${field}`];
return newErrors;
});
}
},
[errors]
);
const validateForm = useCallback(() => {
const newErrors: Record<string, string> = {};
// バリデーションロジック
if (!formData.personalInfo.firstName.trim()) {
newErrors['personalInfo.firstName'] =
'名前は必須です';
}
if (!formData.personalInfo.email.trim()) {
newErrors['personalInfo.email'] =
'メールアドレスは必須です';
}
if (!/\S+@\S+\.\S+/.test(formData.personalInfo.email)) {
newErrors['personalInfo.email'] =
'有効なメールアドレスを入力してください';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [formData]);
const submitForm = useCallback(async () => {
if (!validateForm()) return;
setIsSubmitting(true);
try {
await formAPI.submit(formData);
setFormData(initialFormData);
setErrors({});
} catch (err) {
setErrors({ submit: 'フォームの送信に失敗しました' });
} finally {
setIsSubmitting(false);
}
}, [formData, validateForm]);
const resetForm = useCallback(() => {
setFormData(initialFormData);
setErrors({});
}, []);
const value = {
formData,
errors,
isSubmitting,
updateField,
validateForm,
submitForm,
resetForm,
};
return (
<FormContext.Provider value={value}>
{children}
</FormContext.Provider>
);
}
export function useForm() {
const context = useContext(FormContext);
if (context === undefined) {
throw new Error(
'useForm must be used within a FormProvider'
);
}
return context;
}
Zustand でのフォーム管理:
typescript// form-store.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface FormData {
personalInfo: {
firstName: string;
lastName: string;
email: string;
phone: string;
};
preferences: {
newsletter: boolean;
notifications: boolean;
theme: 'light' | 'dark';
};
address: {
street: string;
city: string;
zipCode: string;
country: string;
};
}
interface FormState {
formData: FormData;
errors: Record<string, string>;
isSubmitting: boolean;
updateField: (
section: keyof FormData,
field: string,
value: any
) => void;
setError: (field: string, error: string) => void;
clearError: (field: string) => void;
validateForm: () => boolean;
submitForm: () => Promise<void>;
resetForm: () => void;
}
const initialFormData: FormData = {
personalInfo: {
firstName: '',
lastName: '',
email: '',
phone: '',
},
preferences: {
newsletter: false,
notifications: true,
theme: 'light',
},
address: {
street: '',
city: '',
zipCode: '',
country: '',
},
};
export const useFormStore = create<FormState>()(
immer((set, get) => ({
formData: initialFormData,
errors: {},
isSubmitting: false,
updateField: (section, field, value) => {
set((state) => {
(state.formData[section] as any)[field] = value;
// エラーをクリア
const errorKey = `${section}.${field}`;
if (state.errors[errorKey]) {
delete state.errors[errorKey];
}
});
},
setError: (field, error) => {
set((state) => {
state.errors[field] = error;
});
},
clearError: (field) => {
set((state) => {
delete state.errors[field];
});
},
validateForm: () => {
const { formData } = get();
const newErrors: Record<string, string> = {};
// バリデーションロジック
if (!formData.personalInfo.firstName.trim()) {
newErrors['personalInfo.firstName'] =
'名前は必須です';
}
if (!formData.personalInfo.email.trim()) {
newErrors['personalInfo.email'] =
'メールアドレスは必須です';
}
if (
!/\S+@\S+\.\S+/.test(formData.personalInfo.email)
) {
newErrors['personalInfo.email'] =
'有効なメールアドレスを入力してください';
}
set((state) => {
state.errors = newErrors;
});
return Object.keys(newErrors).length === 0;
},
submitForm: async () => {
const { validateForm, formData } = get();
if (!validateForm()) return;
set((state) => {
state.isSubmitting = true;
});
try {
await formAPI.submit(formData);
set((state) => {
state.formData = initialFormData;
state.errors = {};
state.isSubmitting = false;
});
} catch (err) {
set((state) => {
state.errors.submit =
'フォームの送信に失敗しました';
state.isSubmitting = false;
});
}
},
resetForm: () => {
set((state) => {
state.formData = initialFormData;
state.errors = {};
});
},
}))
);
グローバル通知システムでの比較
最後に、グローバル通知システムの実装を比較してみましょう。
React Context での通知システム:
typescript// notification-context.tsx
interface Notification {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message: string;
duration?: number;
actions?: Array<{
label: string;
action: () => void;
}>;
}
interface NotificationContextType {
notifications: Notification[];
addNotification: (
notification: Omit<Notification, 'id'>
) => void;
removeNotification: (id: string) => void;
clearAllNotifications: () => void;
}
const NotificationContext = createContext<
NotificationContextType | undefined
>(undefined);
export function NotificationProvider({
children,
}: {
children: React.ReactNode;
}) {
const [notifications, setNotifications] = useState<
Notification[]
>([]);
const addNotification = useCallback(
(notification: Omit<Notification, 'id'>) => {
const id = Math.random().toString(36).substr(2, 9);
const newNotification = { ...notification, id };
setNotifications((prev) => [
...prev,
newNotification,
]);
// 自動削除
if (notification.duration !== 0) {
setTimeout(() => {
removeNotification(id);
}, notification.duration || 5000);
}
},
[]
);
const removeNotification = useCallback((id: string) => {
setNotifications((prev) =>
prev.filter((n) => n.id !== id)
);
}, []);
const clearAllNotifications = useCallback(() => {
setNotifications([]);
}, []);
const value = {
notifications,
addNotification,
removeNotification,
clearAllNotifications,
};
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
);
}
export function useNotifications() {
const context = useContext(NotificationContext);
if (context === undefined) {
throw new Error(
'useNotifications must be used within a NotificationProvider'
);
}
return context;
}
Zustand での通知システム:
typescript// notification-store.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
interface Notification {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message: string;
duration?: number;
actions?: Array<{
label: string;
action: () => void;
}>;
}
interface NotificationState {
notifications: Notification[];
addNotification: (
notification: Omit<Notification, 'id'>
) => void;
removeNotification: (id: string) => void;
clearAllNotifications: () => void;
}
export const useNotificationStore =
create<NotificationState>()(
subscribeWithSelector((set, get) => ({
notifications: [],
addNotification: (notification) => {
const id = Math.random().toString(36).substr(2, 9);
const newNotification = { ...notification, id };
set((state) => ({
notifications: [
...state.notifications,
newNotification,
],
}));
// 自動削除
if (notification.duration !== 0) {
setTimeout(() => {
get().removeNotification(id);
}, notification.duration || 5000);
}
},
removeNotification: (id) => {
set((state) => ({
notifications: state.notifications.filter(
(n) => n.id !== id
),
}));
},
clearAllNotifications: () => {
set({ notifications: [] });
},
}))
);
// 便利なヘルパー関数
export const notify = {
success: (title: string, message: string) => {
useNotificationStore.getState().addNotification({
type: 'success',
title,
message,
});
},
error: (title: string, message: string) => {
useNotificationStore.getState().addNotification({
type: 'error',
title,
message,
duration: 0, // エラーは手動で閉じる
});
},
warning: (title: string, message: string) => {
useNotificationStore.getState().addNotification({
type: 'warning',
title,
message,
});
},
info: (title: string, message: string) => {
useNotificationStore.getState().addNotification({
type: 'info',
title,
message,
});
},
};
まとめ
React Context と Zustand の詳細な比較を通じて、それぞれの特徴と適用場面をご理解いただけたでしょうか。両者にはそれぞれ明確な特徴と利点があり、プロジェクトの要件に応じて適切に選択することが重要です。
React Context の適用場面をまとめますと、React エコシステムに完全に統合された標準機能として、小規模なアプリケーションや限定的なスコープでのステート共有に適しています。特に、既存の React プロジェクトへの導入や、学習コストを抑えたい場合には優れた選択肢となるでしょう。
Zustand の適用場面としては、パフォーマンスが重要視される中〜大規模アプリケーションや、複雑な状態管理ロジックを必要とするプロジェクトに適しています。Provider 不要のシンプルな設計により、開発効率と保守性の両立を実現できます。
具体的な判断基準として、以下の表を参考にしていただければと思います。
# | 要件 | React Context | Zustand |
---|---|---|---|
1 | プロジェクト規模 | 小〜中 | 中〜大 |
2 | パフォーマンス要件 | 標準 | 高い |
3 | 学習コストの制約 | 低い | 中程度 |
4 | TypeScript 活用度 | 標準 | 高い |
5 | 外部ライブラリ使用可否 | 不可 | 可能 |
実装コストの観点では、初期の導入は React Context の方が簡単ですが、機能の拡張や複雑化に伴い、Zustand の方が長期的にはメンテナンスコストが低くなる傾向があります。
最終的な選択は、チームのスキルレベル、プロジェクトの将来性、パフォーマンス要件などを総合的に判断して行うことが重要です。どちらを選択した場合でも、一貫した設計思想を保ち、チーム全体で共通の理解を持つことが成功の鍵となるでしょう。
両者の特性を理解し、適切な場面で適切なツールを選択することで、より効率的で保守性の高い React アプリケーションを構築していただければと思います。
関連リンク
- review
衝撃の事実!『睡眠こそ最強の解決策である』マシュー・ウォーカー著が明かす、99%の人が知らない睡眠の驚くべき真実と人生を変える科学的メカニズム
- review
人生が激変!『嫌われる勇気』岸見一郎・古賀史健著から学ぶ、アドラー心理学で手に入れる真の幸福と自己実現
- review
もう無駄な努力はしない!『イシューからはじめよ』安宅和人著で身につけた、99%の人が知らない本当に価値ある問題の見つけ方
- review
もう朝起きるのが辛くない!『スタンフォード式 最高の睡眠』西野精治著で学んだ、たった 90 分で人生が変わる睡眠革命
- review
もう「なんとなく」で決めない!『解像度を上げる』馬田隆明著で身につけた、曖昧思考を一瞬で明晰にする技術
- review
もう疲れ知らず!『最高の体調』鈴木祐著で手に入れた、一生モノの健康習慣術