T-CREATOR

Zustand の状態管理を使ったカスタムフック作成術

Zustand の状態管理を使ったカスタムフック作成術

React アプリケーションの開発において、状態管理は避けて通れない重要な要素です。特に複雑なアプリケーションでは、コンポーネント間でのデータ共有や状態の管理が困難になりがちです。

そこで注目されているのが、軽量で直感的な状態管理ライブラリ「Zustand」と、React の Custom Hooks を組み合わせた開発手法です。この記事では、実際のプロジェクトで即座に活用できる実践的なテクニックをご紹介します。

初心者の方から中級者の方まで、段階的に理解できるよう具体例を豊富に用意しました。ぜひ最後まで読んで、あなたのプロジェクトに活かしてくださいね。

背景

React での状態管理の複雑化

React アプリケーションの規模が大きくなるにつれて、状態管理の複雑性は増していきます。小規模なプロジェクトでは useStateuseReducer で十分対応できていたものが、機能追加とともにコンポーネント数が増え、データの流れが複雑になってきます。

typescript// 従来の useState による状態管理の例
const MyComponent = () => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  
  // 複数の状態が絡み合う処理
  const handleLogin = async () => {
    setLoading(true);
    setError('');
    try {
      const userData = await loginApi();
      setUser(userData);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };
  
  return (
    // JSX
  );
};

上記のようなコードが複数のコンポーネントに散らばると、同じような状態管理ロジックが重複してしまいます。

Zustand の軽量性と簡潔性

従来の Redux や Context API と比較して、Zustand は驚くほどシンプルな API を提供します。セットアップが簡単で、ボイラープレートコードが最小限に抑えられているのが特徴です。

以下の図は、各状態管理ライブラリの複雑さの比較を示しています。

mermaidflowchart LR
  A[Redux] -->|高い複雑さ| B[多くの設定]
  C[Context API] -->|中程度| D[プロバイダー管理]
  E[Zustand] -->|低い複雑さ| F[最小限の設定]
  
  B --> G[Action, Reducer, Store]
  D --> H[Context, Provider, useContext]
  F --> I[create + カスタムフック]

Zustand は学習コストが低く、すぐに実用的なアプリケーションを構築できる点で優れています。

カスタムフックのメリット

React の Custom Hooks を活用することで、以下のようなメリットが得られます。

メリット説明具体例
再利用性同じロジックを複数のコンポーネントで使い回せる認証状態、API 取得処理
関心の分離UI ロジックとビジネスロジックを分離できるフォーム処理、データ変換
テスタビリティフック単体でのテストが可能ユニットテスト対応
可読性向上コンポーネントがスリムになり理解しやすくなる責務の明確化

これらのメリットを Zustand と組み合わせることで、保守性の高い状態管理システムを構築できるのです。

課題

状態ロジックの再利用性の問題

React アプリケーションでよく遭遇する問題の一つが、状態管理ロジックの重複です。例えば、ユーザー認証の処理を複数のコンポーネントで実装する際、同じようなコードが各所に散らばってしまいます。

typescript// 問題のあるコード例:複数箇所で同様の認証ロジックが重複
// LoginPage.tsx
const LoginPage = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [user, setUser] = useState(null);
  const [error, setError] = useState('');

  const handleLogin = async (credentials) => {
    setIsLoading(true);
    try {
      const response = await authApi.login(credentials);
      setUser(response.user);
      localStorage.setItem('token', response.token);
    } catch (err) {
      setError(err.message);
    } finally {
      setIsLoading(false);
    }
  };
  // ...
};
typescript// Header.tsx でも同様のロジック
const Header = () => {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const checkAuth = async () => {
      setIsLoading(true);
      try {
        const token = localStorage.getItem('token');
        if (token) {
          const userData = await authApi.getProfile();
          setUser(userData);
        }
      } catch (err) {
        console.error(err);
      } finally {
        setIsLoading(false);
      }
    };
    checkAuth();
  }, []);
  // ...
};

このような重複により、コードの保守性が大幅に低下してしまいます。

コンポーネント間での状態共有の難しさ

関連のないコンポーネント同士で状態を共有する際、従来の方法では以下のような問題が発生します。

mermaidflowchart TD
  A[App Component] --> B[Header]
  A --> C[Main Content]
  A --> D[Footer]
  
  B --> E[User Menu]
  C --> F[Product List]
  C --> G[Shopping Cart]
  
  subgraph "問題点"
  H[Props Drilling] --> I[深いネスト]
  J[Context 乱用] --> K[再レンダリング頻発]
  L[状態分散] --> M[データの整合性問題]
  end

Context API を使った場合でも、プロバイダーの配置や更新による無駄な再レンダリングが課題となりがちです。

複雑な状態更新ロジックの管理

実際のアプリケーションでは、単純な状態の読み書きだけでなく、複雑なビジネスロジックを含む状態更新が必要になります。

typescript// 複雑な状態更新の例(ECサイトのカート機能)
const CartComponent = () => {
  const [items, setItems] = useState([]);
  const [discount, setDiscount] = useState(0);
  const [shipping, setShipping] = useState(0);
  const [tax, setTax] = useState(0);

  // 商品追加時の複雑な計算処理
  const addItem = (product) => {
    setItems(prev => {
      const existing = prev.find(item => item.id === product.id);
      if (existing) {
        const updatedItems = prev.map(item => 
          item.id === product.id 
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
        // 割引、送料、税額の再計算
        calculatePricing(updatedItems);
        return updatedItems;
      } else {
        const newItems = [...prev, { ...product, quantity: 1 }];
        calculatePricing(newItems);
        return newItems;
      }
    });
  };

  const calculatePricing = (items) => {
    // 複雑な計算ロジック...
    const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    setDiscount(calculateDiscount(subtotal));
    setShipping(calculateShipping(items));
    setTax(calculateTax(subtotal));
  };
  // ...
};

このようなロジックがコンポーネント内に混在すると、テストが困難になり、バグの温床となってしまうのが現実です。さらに、同様の計算が他のコンポーネントでも必要になった場合、コードの重複が避けられません。

解決策

Zustand を活用したカスタムフックパターン

これらの課題を解決するために、Zustand とカスタムフックを組み合わせたアプローチを採用します。まず、Zustand の基本的なセットアップから始めましょう。

bash# Zustand のインストール
yarn add zustand
typescript// types/auth.ts - 型定義の分離
export interface User {
  id: string;
  name: string;
  email: string;
  avatar?: string;
}

export interface AuthState {
  user: User | null;
  isLoading: boolean;
  error: string | null;
}

Zustand のストアは以下のように作成します。

typescript// stores/authStore.ts - Zustand ストアの定義
import { create } from 'zustand';
import { AuthState, User } from '../types/auth';

interface AuthActions {
  setUser: (user: User | null) => void;
  setLoading: (loading: boolean) => void;
  setError: (error: string | null) => void;
  clearAuth: () => void;
}

export const useAuthStore = create<AuthState & AuthActions>((set) => ({
  // 初期状態
  user: null,
  isLoading: false,
  error: null,
  
  // アクション
  setUser: (user) => set({ user }),
  setLoading: (isLoading) => set({ isLoading }),
  setError: (error) => set({ error }),
  clearAuth: () => set({ user: null, error: null, isLoading: false }),
}));

状態とロジックの分離手法

ビジネスロジックをカスタムフックに分離することで、コンポーネントをスリムに保てます。

typescript// hooks/useAuth.ts - 認証ロジックを含むカスタムフック
import { useCallback } from 'react';
import { useAuthStore } from '../stores/authStore';
import { authApi } from '../api/auth';
import { User } from '../types/auth';

interface LoginCredentials {
  email: string;
  password: string;
}

export const useAuth = () => {
  // Zustand ストアから状態とアクションを取得
  const { user, isLoading, error, setUser, setLoading, setError, clearAuth } = useAuthStore();

  // ログイン処理
  const login = useCallback(async (credentials: LoginCredentials) => {
    setLoading(true);
    setError(null);
    
    try {
      const response = await authApi.login(credentials);
      setUser(response.user);
      localStorage.setItem('token', response.token);
      return response.user;
    } catch (err) {
      const errorMessage = err instanceof Error ? err.message : 'ログインに失敗しました';
      setError(errorMessage);
      throw err;
    } finally {
      setLoading(false);
    }
  }, [setUser, setLoading, setError]);

  return {
    // 状態
    user,
    isLoading,
    error,
    // アクション
    login,
    clearAuth,
  };
};

このカスタムフックにより、複雑なロジックがコンポーネントから分離され、複数の場所で再利用できるようになりました。

型安全性を保った実装方法

TypeScript を活用した型安全なカスタムフック実装について解説します。

typescript// hooks/useAuth.ts の続き - エラーハンドリングの型安全性
interface AuthError {
  type: 'NETWORK_ERROR' | 'VALIDATION_ERROR' | 'SERVER_ERROR';
  message: string;
  details?: Record<string, string>;
}

// カスタムエラークラス
class AuthenticationError extends Error {
  constructor(
    message: string,
    public type: AuthError['type'],
    public details?: Record<string, string>
  ) {
    super(message);
    this.name = 'AuthenticationError';
  }
}
typescript// hooks/useAuth.ts - 型安全なエラーハンドリング
export const useAuth = () => {
  // 省略...

  const handleAuthError = useCallback((error: unknown): AuthError => {
    if (error instanceof AuthenticationError) {
      return {
        type: error.type,
        message: error.message,
        details: error.details,
      };
    }
    
    if (error instanceof Error) {
      return {
        type: 'NETWORK_ERROR',
        message: error.message,
      };
    }
    
    return {
      type: 'SERVER_ERROR',
      message: '予期しないエラーが発生しました',
    };
  }, []);

  return {
    user,
    isLoading,
    error,
    login,
    clearAuth,
    handleAuthError,
  };
};

このアプローチにより、エラーハンドリングも型安全に行えます。コンポーネント側では以下のように使用できます。

typescript// components/LoginForm.tsx - カスタムフックの使用例
import { useAuth } from '../hooks/useAuth';

const LoginForm = () => {
  const { login, isLoading, error } = useAuth();

  const handleSubmit = async (credentials) => {
    try {
      await login(credentials);
      // ログイン成功時の処理
    } catch (err) {
      // エラーは既にフック内で処理されている
      console.log('ログインに失敗しました');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <div className="error">{error}</div>}
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  );
};

以下の図は、Zustand とカスタムフックを組み合わせた アーキテクチャの全体像を示しています。

mermaidflowchart TB
  A[Component] -->|使用| B[Custom Hook]
  B -->|状態管理| C[Zustand Store]
  B -->|API通信| D[API Service]
  
  C --> E[State]
  C --> F[Actions]
  
  subgraph "メリット"
  G[再利用性] --> H[複数コンポーネントで共有]
  I[テスタビリティ] --> J[フック単体テスト]
  K[型安全性] --> L[TypeScript サポート]
  end

図で理解できる要点:

  • コンポーネントはカスタムフック経由でのみ状態にアクセス
  • ビジネスロジックがフックに集約され再利用可能
  • API サービスとの結合が疎結合で保たれている

具体例

実際のプロジェクトで使える4つの実践的なカスタムフックを詳しく解説します。各例では、Zustand を活用した状態管理とビジネスロジックの分離を実現しています。

ユーザー認証状態管理フック

先ほど紹介した認証フックを、さらに実用的に拡張してみましょう。

typescript// stores/authStore.ts - 拡張された認証ストア
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface AuthState {
  user: User | null;
  token: string | null;
  isLoading: boolean;
  error: string | null;
  isAuthenticated: boolean;
}

interface AuthActions {
  setUser: (user: User | null) => void;
  setToken: (token: string | null) => void;
  setLoading: (loading: boolean) => void;
  setError: (error: string | null) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState & AuthActions>()(
  persist(
    (set, get) => ({
      user: null,
      token: null,
      isLoading: false,
      error: null,
      isAuthenticated: false,
      
      setUser: (user) => set({ 
        user, 
        isAuthenticated: !!user 
      }),
      setToken: (token) => set({ token }),
      setLoading: (isLoading) => set({ isLoading }),
      setError: (error) => set({ error }),
      logout: () => set({ 
        user: null, 
        token: null, 
        isAuthenticated: false,
        error: null 
      }),
    }),
    {
      name: 'auth-storage',
      partialize: (state) => ({ 
        token: state.token, 
        user: state.user 
      }),
    }
  )
);
typescript// hooks/useAuth.ts - 完全版の認証フック
import { useEffect, useCallback } from 'react';
import { useAuthStore } from '../stores/authStore';
import { authApi } from '../api/auth';

export const useAuth = () => {
  const authStore = useAuthStore();
  
  // 初期化時のトークン検証
  const initializeAuth = useCallback(async () => {
    const { token, setUser, setLoading, logout } = authStore;
    
    if (!token) return;
    
    setLoading(true);
    try {
      const user = await authApi.verifyToken(token);
      setUser(user);
    } catch (error) {
      console.error('Token verification failed:', error);
      logout(); // 無効なトークンの場合はログアウト
    } finally {
      setLoading(false);
    }
  }, [authStore]);

  // アプリケーション起動時の初期化
  useEffect(() => {
    initializeAuth();
  }, [initializeAuth]);

  const login = useCallback(async (credentials: LoginCredentials) => {
    const { setUser, setToken, setLoading, setError } = authStore;
    
    setLoading(true);
    setError(null);
    
    try {
      const response = await authApi.login(credentials);
      setUser(response.user);
      setToken(response.token);
      return response;
    } catch (error) {
      const message = error instanceof Error ? error.message : 'ログインに失敗しました';
      setError(message);
      throw error;
    } finally {
      setLoading(false);
    }
  }, [authStore]);

  return {
    ...authStore,
    login,
    initializeAuth,
  };
};

ショッピングカート管理フック

ECサイトでよく使われるカート機能を実装してみましょう。

typescript// types/cart.ts - カート関連の型定義
export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
  image?: string;
  options?: Record<string, string>;
}

export interface CartState {
  items: CartItem[];
  total: number;
  itemCount: number;
  discount: number;
  shipping: number;
  tax: number;
}
typescript// stores/cartStore.ts - カートのZustandストア
import { create } from 'zustand';
import { CartState, CartItem } from '../types/cart';

interface CartActions {
  addItem: (item: Omit<CartItem, 'quantity'>, quantity?: number) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  calculateTotals: () => void;
}

export const useCartStore = create<CartState & CartActions>((set, get) => ({
  items: [],
  total: 0,
  itemCount: 0,
  discount: 0,
  shipping: 0,
  tax: 0,
  
  addItem: (newItem, quantity = 1) => {
    const { items } = get();
    const existingItem = items.find(item => item.id === newItem.id);
    
    if (existingItem) {
      get().updateQuantity(existingItem.id, existingItem.quantity + quantity);
    } else {
      const item: CartItem = { ...newItem, quantity };
      set({ items: [...items, item] });
      get().calculateTotals();
    }
  },
  
  removeItem: (id) => {
    const { items } = get();
    const filteredItems = items.filter(item => item.id !== id);
    set({ items: filteredItems });
    get().calculateTotals();
  },
  
  updateQuantity: (id, quantity) => {
    const { items } = get();
    if (quantity <= 0) {
      get().removeItem(id);
      return;
    }
    
    const updatedItems = items.map(item =>
      item.id === id ? { ...item, quantity } : item
    );
    set({ items: updatedItems });
    get().calculateTotals();
  },
  
  clearCart: () => {
    set({ 
      items: [], 
      total: 0, 
      itemCount: 0, 
      discount: 0, 
      shipping: 0, 
      tax: 0 
    });
  },
  
  calculateTotals: () => {
    const { items } = get();
    const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
    const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    
    // 割引計算(例:10,000円以上で10%割引)
    const discount = subtotal >= 10000 ? subtotal * 0.1 : 0;
    
    // 送料計算(例:5,000円以上で送料無料)
    const shipping = subtotal >= 5000 ? 0 : 500;
    
    // 税額計算(例:10%)
    const tax = (subtotal - discount) * 0.1;
    
    const total = subtotal - discount + shipping + tax;
    
    set({ total, itemCount, discount, shipping, tax });
  },
}));
typescript// hooks/useCart.ts - カート管理のカスタムフック
import { useCallback } from 'react';
import { useCartStore } from '../stores/cartStore';
import { CartItem } from '../types/cart';

export const useCart = () => {
  const cartStore = useCartStore();
  
  // 商品追加のラッパー関数(バリデーション付き)
  const addToCart = useCallback((product: Omit<CartItem, 'quantity'>, quantity: number = 1) => {
    if (quantity <= 0) {
      throw new Error('数量は1以上である必要があります');
    }
    
    if (!product.id || !product.name || product.price < 0) {
      throw new Error('商品情報が不正です');
    }
    
    cartStore.addItem(product, quantity);
  }, [cartStore]);
  
  // 商品が既にカートに入っているかチェック
  const isInCart = useCallback((productId: string): boolean => {
    return cartStore.items.some(item => item.id === productId);
  }, [cartStore.items]);
  
  // 特定の商品の数量を取得
  const getItemQuantity = useCallback((productId: string): number => {
    const item = cartStore.items.find(item => item.id === productId);
    return item?.quantity || 0;
  }, [cartStore.items]);

  return {
    ...cartStore,
    addToCart,
    isInCart,
    getItemQuantity,
  };
};

モーダル表示制御フック

アプリケーション全体でモーダルの表示を管理するフックです。

typescript// stores/modalStore.ts - モーダル管理のストア
import { create } from 'zustand';

interface ModalData {
  title?: string;
  content?: React.ReactNode;
  props?: Record<string, any>;
}

interface ModalState {
  isOpen: boolean;
  modalType: string | null;
  data: ModalData;
}

interface ModalActions {
  openModal: (type: string, data?: ModalData) => void;
  closeModal: () => void;
  updateData: (data: Partial<ModalData>) => void;
}

export const useModalStore = create<ModalState & ModalActions>((set) => ({
  isOpen: false,
  modalType: null,
  data: {},
  
  openModal: (modalType, data = {}) => {
    set({ isOpen: true, modalType, data });
  },
  
  closeModal: () => {
    set({ isOpen: false, modalType: null, data: {} });
  },
  
  updateData: (newData) => {
    set((state) => ({ 
      data: { ...state.data, ...newData } 
    }));
  },
}));
typescript// hooks/useModal.ts - モーダル制御フック
import { useCallback } from 'react';
import { useModalStore } from '../stores/modalStore';

// よく使用されるモーダルタイプの定数
export const MODAL_TYPES = {
  CONFIRMATION: 'confirmation',
  ALERT: 'alert',
  USER_PROFILE: 'userProfile',
  PRODUCT_DETAIL: 'productDetail',
} as const;

export const useModal = () => {
  const modalStore = useModalStore();
  
  // 確認ダイアログを開く便利メソッド
  const openConfirmation = useCallback((
    title: string,
    message: string,
    onConfirm: () => void,
    onCancel?: () => void
  ) => {
    modalStore.openModal(MODAL_TYPES.CONFIRMATION, {
      title,
      content: message,
      props: { onConfirm, onCancel },
    });
  }, [modalStore]);
  
  // アラートダイアログを開く便利メソッド
  const openAlert = useCallback((title: string, message: string) => {
    modalStore.openModal(MODAL_TYPES.ALERT, {
      title,
      content: message,
    });
  }, [modalStore]);
  
  // 特定のモーダルタイプかどうかをチェック
  const isModalType = useCallback((type: string): boolean => {
    return modalStore.modalType === type;
  }, [modalStore.modalType]);

  return {
    ...modalStore,
    openConfirmation,
    openAlert,
    isModalType,
  };
};

API データ取得・キャッシュフック

データフェッチングとキャッシュ機能を持つ汎用的なフックです。

typescript// stores/dataStore.ts - データキャッシュのストア
import { create } from 'zustand';

interface CacheEntry<T = any> {
  data: T;
  timestamp: number;
  isLoading: boolean;
  error: string | null;
}

interface DataState {
  cache: Record<string, CacheEntry>;
  globalLoading: boolean;
}

interface DataActions {
  setData: <T>(key: string, data: T) => void;
  setLoading: (key: string, isLoading: boolean) => void;
  setError: (key: string, error: string | null) => void;
  clearCache: (key?: string) => void;
  getCacheEntry: <T>(key: string) => CacheEntry<T> | undefined;
}

const CACHE_DURATION = 5 * 60 * 1000; // 5分間のキャッシュ

export const useDataStore = create<DataState & DataActions>((set, get) => ({
  cache: {},
  globalLoading: false,
  
  setData: (key, data) => {
    set((state) => ({
      cache: {
        ...state.cache,
        [key]: {
          data,
          timestamp: Date.now(),
          isLoading: false,
          error: null,
        },
      },
    }));
  },
  
  setLoading: (key, isLoading) => {
    set((state) => ({
      cache: {
        ...state.cache,
        [key]: {
          ...state.cache[key],
          isLoading,
        },
      },
    }));
  },
  
  setError: (key, error) => {
    set((state) => ({
      cache: {
        ...state.cache,
        [key]: {
          ...state.cache[key],
          error,
          isLoading: false,
        },
      },
    }));
  },
  
  clearCache: (key) => {
    if (key) {
      set((state) => {
        const { [key]: removed, ...rest } = state.cache;
        return { cache: rest };
      });
    } else {
      set({ cache: {} });
    }
  },
  
  getCacheEntry: (key) => {
    const entry = get().cache[key];
    if (!entry) return undefined;
    
    // キャッシュの有効期限をチェック
    const isExpired = Date.now() - entry.timestamp > CACHE_DURATION;
    if (isExpired) {
      get().clearCache(key);
      return undefined;
    }
    
    return entry;
  },
}));
typescript// hooks/useApi.ts - API データ取得フック
import { useEffect, useCallback } from 'react';
import { useDataStore } from '../stores/dataStore';

interface UseApiOptions {
  immediate?: boolean;
  cacheKey?: string;
  dependencies?: any[];
}

export const useApi = <T = any>(
  apiFunction: () => Promise<T>,
  options: UseApiOptions = {}
) => {
  const { immediate = true, cacheKey, dependencies = [] } = options;
  const dataStore = useDataStore();
  
  // キャッシュキーを生成
  const generateCacheKey = useCallback(() => {
    if (cacheKey) return cacheKey;
    return `api_${apiFunction.toString()}_${JSON.stringify(dependencies)}`;
  }, [cacheKey, apiFunction, dependencies]);
  
  const key = generateCacheKey();
  const cacheEntry = dataStore.getCacheEntry<T>(key);
  
  // データを取得する関数
  const fetchData = useCallback(async (forceRefresh = false) => {
    // キャッシュがあり、強制リフレッシュでない場合はキャッシュを返す
    if (!forceRefresh && cacheEntry && !cacheEntry.isLoading) {
      return cacheEntry.data;
    }
    
    dataStore.setLoading(key, true);
    dataStore.setError(key, null);
    
    try {
      const result = await apiFunction();
      dataStore.setData(key, result);
      return result;
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : 'APIエラーが発生しました';
      dataStore.setError(key, errorMessage);
      throw error;
    }
  }, [apiFunction, dataStore, key, cacheEntry]);
  
  // 初回実行
  useEffect(() => {
    if (immediate) {
      fetchData();
    }
  }, [immediate, ...dependencies]);
  
  return {
    data: cacheEntry?.data,
    isLoading: cacheEntry?.isLoading || false,
    error: cacheEntry?.error,
    fetchData,
    refetch: () => fetchData(true),
    clearCache: () => dataStore.clearCache(key),
  };
};

以下の図は、4つのカスタムフックの関係性と用途を示しています。

mermaidflowchart LR
  subgraph "カスタムフック例"
    A[useAuth<br/>認証管理]
    B[useCart<br/>カート管理]
    C[useModal<br/>モーダル制御]
    D[useApi<br/>データ取得]
  end
  
  subgraph "用途・シーン"
    E[ユーザーセッション]
    F[ECサイト機能]
    G[UI制御]
    H[データキャッシュ]
  end
  
  A --> E
  B --> F
  C --> G
  D --> H
  
  subgraph "共通メリット"
    I[再利用性] --> J[複数画面で利用]
    K[保守性] --> L[ロジック集約]
    M[テスト性] --> N[単体テスト対応]
  end

図で理解できる要点:

  • 各フックは特定のドメインに特化した機能を提供
  • ビジネスロジックが適切に分離されている
  • 横断的な関心事(エラーハンドリング、キャッシュ)も統一的に処理

まとめ

カスタムフック作成のベストプラクティス

今回の記事を通じて、Zustand を活用したカスタムフックの作成方法について詳しく解説してきました。実践的なアプローチを採用することで、よりよい開発体験を実現できます。

基本原則として押さえておきたいポイントは以下の通りです。

原則説明実装例
単一責任の原則1つのフックは1つの関心事に専念するuseAuth、useCart など
型安全性の確保TypeScript を活用してランタイムエラーを防ぐ厳密な型定義とエラーハンドリング
再利用性の設計複数のコンポーネントで使い回せる汎用的な設計設定可能なオプション引数
テスタビリティビジネスロジックを分離してテストしやすくするPure function での実装

これらの原則に従うことで、保守性が高く、スケーラブルなReact アプリケーションを構築できるでしょう。

保守性向上のポイント

長期間にわたってコードベースを健全に保つために、以下の点を意識することが重要です。

1. ディレクトリ構造の統一化

typescriptsrc/
├── hooks/          // カスタムフック
│   ├── useAuth.ts
│   ├── useCart.ts
│   ├── useModal.ts
│   └── useApi.ts
├── stores/         // Zustand ストア
│   ├── authStore.ts
│   ├── cartStore.ts
│   ├── modalStore.ts
│   └── dataStore.ts
├── types/          // 型定義
│   ├── auth.ts
│   ├── cart.ts
│   └── common.ts
└── api/            // API 関連
    ├── auth.ts
    ├── products.ts
    └── client.ts

この構造により、開発者は直感的にファイルを見つけることができ、新しいメンバーのオンボーディングもスムーズになります。

2. エラーハンドリングの統一化

typescript// utils/errorHandler.ts - 統一エラーハンドラー
export class AppError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode?: number
  ) {
    super(message);
    this.name = 'AppError';
  }
}

export const handleApiError = (error: unknown): AppError => {
  if (error instanceof AppError) return error;
  
  if (error instanceof Error) {
    return new AppError(error.message, 'UNKNOWN_ERROR');
  }
  
  return new AppError('予期しないエラーが発生しました', 'UNEXPECTED_ERROR');
};

3. パフォーマンス最適化の考慮

Zustand のshallow比較を使用して、不要な再レンダリングを防ぎましょう。

typescript// hooks/useAuthOptimized.ts - 最適化版
import { shallow } from 'zustand/shallow';
import { useAuthStore } from '../stores/authStore';

export const useAuthOptimized = () => {
  // 必要な状態のみを選択的に取得
  const { user, isLoading } = useAuthStore(
    (state) => ({ 
      user: state.user, 
      isLoading: state.isLoading 
    }),
    shallow
  );
  
  return { user, isLoading };
};

4. ドキュメントとテストの充実

typescript/**
 * 認証状態を管理するカスタムフック
 * 
 * @example
 * ```tsx
 * const MyComponent = () => {
 *   const { user, login, logout, isLoading } = useAuth();
 *   
 *   return (
 *     <div>
 *       {user ? (
 *         <button onClick={logout}>ログアウト</button>
 *       ) : (
 *         <button onClick={() => login({ email: '...', password: '...' })}>
 *           ログイン
 *         </button>
 *       )}
 *     </div>
 *   );
 * };
 * ```
 */
export const useAuth = () => {
  // 実装...
};

導入時の注意点

実際のプロジェクトに導入する際は、以下の段階的アプローチをお勧めします。

  1. 小規模から始める: まずは1つのシンプルなフックから導入する
  2. チーム内での合意形成: コーディング規約やファイル構成についてチーム全体で合意を取る
  3. 段階的な移行: 既存のコードを一度にリファクタリングするのではなく、新機能から適用する
  4. 継続的な改善: レビューを通じて徐々にパターンを洗練させる

このアプローチにより、Zustand とカスタムフックを活用した、より良い React アプリケーション開発が実現できます。皆様のプロジェクトでも、ぜひこれらのテクニックを活用してみてくださいね。

実装の過程で疑問や課題が生じた場合は、コミュニティや公式ドキュメントを積極的に活用し、継続的な学習を心がけることが成功の鍵となるでしょう。

関連リンク