React カスタムフック実践ガイド|実務で使える実装パターンとサンプルコード

React / Next.js

React カスタムフック実践ガイド|実務で使える実装パターンとサンプルコード

React開発の中で、状態ロジックの再利用性を高めるために欠かせないのがカスタムフックです。しかし、教科書的なサンプルコードだけでは実務では活用しづらいというのが実情です。この記事では、実際のプロジェクトで使用されているカスタムフックの実装パターンを、具体的なユースケースとともに紹介します。

カスタムフックとは|簡易的な解説

カスタムフックは、React のビルトインフック(useState、useEffect など)を組み合わせて、再利用可能なロジックを抽出したJavaScript関数です。コンポーネント間で状態管理ロジックを共有するための強力なツールであり、DRY原則に基づいた設計を実現します。

通常のReactフックと同様に、use で始まる名前をつけることが命名規約となっています。

// 最もシンプルなカスタムフックの例
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  
  return { count, increment, decrement };
}

業務でのユースケース|実務で本当に必要な場面

実務開発では、以下のようなシーンでカスタムフックが活躍します。

1. API通信とエラーハンドリング

外部APIからデータ取得する際のローディング状態、エラー処理、キャッシュ管理などの複雑な処理を複数コンポーネントで繰り返すことになります。これらをカスタムフック化することで、各コンポーネントは取得したデータを使うことだけに専念できます。

2. フォーム管理

入力フィールドの値管理、バリデーション、送信処理など、複数ページやモーダルで同じパターンが登場します。

3. ローカルストレージとの連携

ユーザー設定やドラフト保存など、ブラウザローカルストレージに状態を同期させる処理は頻出です。

4. タイマーと定期実行

カウントダウン、自動保存、ハートビート通信など、時間ベースの処理はカスタムフック化するとメモリリークを防ぎやすくなります。

実装コード|実務レベルのサンプル

パターン1: API通信フック(実務版)

最初のパターンは、API通信を扱うカスタムフックです。実務では、リトライロジック、タイムアウト、レスポンスキャッシング、複数パラメータへの対応が必要になります。

// useApi.ts - 実務で使われるAPI通信フック
import { useState, useEffect, useCallback, useRef } from 'react';

interface UseApiOptions {
  immediate?: boolean;
  retryCount?: number;
  timeout?: number;
  cacheTime?: number; // ミリ秒
}

interface CacheEntry {
  data: any;
  timestamp: number;
}

// グローバルキャッシュ(本来はReduxやReactQueryを使うが、シンプル例として)
const apiCache = new Map();

function useApi(
  url: string,
  options: UseApiOptions = {}
) {
  const {
    immediate = true,
    retryCount = 3,
    timeout = 10000,
    cacheTime = 60000, // デフォルト1分
  } = options;

  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const abortControllerRef = useRef(null);
  const retryCountRef = useRef(0);

  const fetchData = useCallback(async () => {
    // キャッシュチェック
    const cached = apiCache.get(url);
    if (cached && Date.now() - cached.timestamp < cacheTime) {
      setData(cached.data);
      return;
    }

    setLoading(true);
    setError(null);
    abortControllerRef.current = new AbortController();

    try {
      const timeoutId = setTimeout(
        () => abortControllerRef.current?.abort(),
        timeout
      );

      const response = await fetch(url, {
        signal: abortControllerRef.current.signal,
      });

      clearTimeout(timeoutId);

      if (!response.ok) {
        throw new Error(`HTTP Error: ${response.status}`);
      }

      const result = await response.json();
      
      // キャッシュに保存
      apiCache.set(url, {
        data: result,
        timestamp: Date.now(),
      });

      setData(result);
      retryCountRef.current = 0;
    } catch (err) {
      if (err instanceof Error && err.name === 'AbortError') {
        return; // キャンセル時は処理しない
      }

      if (retryCountRef.current < retryCount) {
        retryCountRef.current += 1;
        // エクスポーネンシャルバックオフ
        setTimeout(
          () => fetchData(),
          Math.pow(2, retryCountRef.current) * 1000
        );
      } else {
        setError(err instanceof Error ? err : new Error('Unknown error'));
        setLoading(false);
      }
    } finally {
      setLoading(false);
    }
  }, [url, retryCount, timeout, cacheTime]);

  useEffect(() => {
    if (immediate) {
      fetchData();
    }

    return () => {
      abortControllerRef.current?.abort();
    };
  }, [url, immediate, fetchData]);

  const refetch = useCallback(() => {
    apiCache.delete(url); // キャッシュクリア
    retryCountRef.current = 0;
    fetchData();
  }, [url, fetchData]);

  return { data, loading, error, refetch };
}

// 使用例
function UserList() {
  const { data: users, loading, error, refetch } = useApi(
    'https://api.example.com/users',
    { cacheTime: 5 * 60 * 1000 } // 5分キャッシュ
  );

  if (loading) return 
読み込み中...
; if (error) return
エラー: {error.message}
; return (
    {users?.map(user => (
  • {user.name}
  • ))}
); }

パターン2: フォーム管理フック(バリデーション付き)

複数のフォームフィールドを扱う場合、各入力の値、エラー、検証状態を一元管理するフックは非常に便利です。実務では、バリデーションルールが複雑になることが多いため、ここではそれに対応した実装を示します。

// useForm.ts - フォーム管理フック
import { useState, useCallback, useRef } from 'react';

type ValidationRule = (value: any) => string | null; // エラーメッセージ、nullなら成功

interface FormField {
  value: any;
  error: string | null;
  touched: boolean;
}

interface FormConfig {
  [key: string]: {
    initialValue: any;
    rules?: ValidationRule[];
  };
}

function useForm(config: FormConfig) {
  const [fields, setFields] = useState>(() => {
    const initial: Record = {};
    Object.entries(config).forEach(([key, { initialValue }]) => {
      initial[key] = {
        value: initialValue,
        error: null,
        touched: false,
      };
    });
    return initial;
  });

  const isSubmittingRef = useRef(false);

  const validateField = useCallback(
    (fieldName: string, value: any) => {
      const rules = config[fieldName]?.rules || [];
      
      for (const rule of rules) {
        const error = rule(value);
        if (error) return error;
      }
      return null;
    },
    [config]
  );

  const handleChange = useCallback(
    (e: React.ChangeEvent) => {
      const { name, value, type } = e.target;
      const finalValue = type === 'checkbox' ? (e.target as HTMLInputElement).checked : value;

      setFields(prev => ({
        ...prev,
        [name]: {
          ...prev[name],
          value: finalValue,
          // リアルタイムバリデーション(既にタッチされている場合のみ)
          error: prev[name].touched ? validateField(name, finalValue) : null,
        },
      }));
    },
    [validateField]
  );

  const handleBlur = useCallback(
    (e: React.FocusEvent) => {
      const { name } = e.target;

      setFields(prev => ({
        ...prev,
        [name]: {
          ...prev[name],
          touched: true,
          error: validateField(name, prev[name].value),
        },
      }));
    },
    [validateField]
  );

  const handleSubmit = useCallback(
    (onSubmit: (values: Record) => void | Promise) => {
      return async (e: React.FormEvent) => {
        e.preventDefault();
        
        if (isSubmittingRef.current) return;
        isSubmittingRef.current = true;

        // 全フィールドを検証
        const newErrors: Record = {};
        let hasError = false;

        Object.entries(fields).forEach(([key, field]) => {
          const error = validateField(key, field.value);
          newErrors[key] = error;
          if (error) hasError = true;
        });

        // エラー状態を更新
        setFields(prev => {
          const updated = { ...prev };
          Object.entries(newErrors).forEach(([key, error]) => {
            updated[key] = {
              ...updated[key],
              error,
              touched: true,
            };
          });
          return updated;
        });

        if (!hasError) {
          try {
            const values = Object.entries(fields).reduce(
              (acc, [key, field]) => ({
                ...acc,
                [key]: field.value,
              }),
              {}
            );
            await onSubmit(values);
          } catch (err) {
            console.error('Form submission error:', err);
          } finally {
            isSubmittingRef.current = false;
          }
        } else {
          isSubmittingRef.current = false;
        }
      };
    },
    [fields, validateField]
  );

  const reset = useCallback(() => {
    const initial: Record = {};
    Object.entries(config).forEach(([key, { initialValue }]) => {
      initial[key] = {
        value: initialValue,
        error: null,
        touched: false,
      };
    });
    setFields(initial);
  }, [config]);

  const getFieldProps = useCallback(
    (fieldName: string) => ({
      name: fieldName,
      value: fields[fieldName]?.value || '',
      onChange: handleChange,
      onBlur: handleBlur,
    }),
    [fields, handleChange, handleBlur]
  );

  return {
    fields,
    handleSubmit,
    getFieldProps,
    reset,
  };
}

// バリデーションルール定義
const emailRule: ValidationRule = (value) => {
  const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
  return emailRegex.test(value) ? null : 'メールアドレスが無効です';
};

const requiredRule: ValidationRule = (value) => {
  return value && value.toString().trim() ? null : '必須項目です';
};

const minLengthRule = (min: number): ValidationRule => (value) => {
  return value && value.length >= min ? null : `最小${min}文字です`;
};

// 使用例
function LoginForm() {
  const { fields, handleSubmit, getFieldProps, reset } = useForm({
    email: {
      initialValue: '',
      rules: [requiredRule, emailRule],
    },
    password: {
      initialValue: '',
      rules: [requiredRule, minLengthRule(8)],
    },
  });

  const onSubmit = async (values: Record) => {
    console.log('送信:', values);
    // API呼び出しなど
    reset();
  };

  return (
    
{fields.email.touched && fields.email.error && ( {fields.email.error} )}
{fields.password.touched && fields.password.error && ( {fields.password.error} )}
); }

パターン3: ローカルストレージ同期フック

ユーザー設定やドラフトデータをローカルストレージに自動保存するパターンも業務では頻出です。以下は、JSONシリアライズ対応、エラーハンドリング付きの実装です。

// useLocalStorage.ts - ローカルストレージ同期フック
import { useState, useEffect, useCallback } from 'react';

function useLocalStorage(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`localStorage読み込みエラー (${key}):`, error);
      return initialValue;
    }
  });

  const setValue = useCallback(
    (value: T | ((val: T) => T)) => {
      try {
        const valueToStore = value instanceof Function ? value(storedValue) : value;
        setStoredValue(valueToStore);
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
        
        // ストレージイベントをディスパッチ(他のタブとの同期用)
        window.dispatchEvent(
          new StorageEvent('storage', {
            key,
            newValue: JSON.stringify(valueToStore),
          })
        );
      } catch (error) {
        console.error(`localStorage書き込みエラー (${key}):`, error);
      }
    },
    [key, storedValue]
  );

  // 他のタブでの変更を検知
  useEffect(() => {
    const handleStorageChange = (e: StorageEvent) => {
      if (e.key === key && e.newValue) {
        try {
          setStoredValue(JSON.parse(e.newValue));
        } catch (error) {
          console.error('ストレージ同期エラー:', error);
        }
      }
    };

    window.addEventListener('storage', handleStorageChange);
    return () => window.removeEventListener('storage', handleStorageChange);
  }, [key]);

  return [storedValue, setValue] as const;
}

// 使用例
interface UserPreferences {
  theme: 'light' | 'dark';
  language: string;
  sidebarCollapsed: boolean;
}

function SettingsPanel() {
  const [preferences, setPreferences] = useLocalStorage(
    'userPreferences',
    {
      theme: 'light',
      language: 'ja',
      sidebarCollapsed: false,
    }
  );

  const handleThemeChange = (theme: 'light' | 'dark') => {
    setPreferences(prev => ({ ...prev, theme }));
  };

  return (
    

設定

現在: {preferences.theme}

); }

よくある応用パターン

複数フックの組み合わせ

実務では、複数のカスタムフックを組み合わせることで、より複雑な機能を実現します。例えば、APIからデータを取得し、そのデータをフォームで編集し、変更をローカルストレージに保存する場合を考えます。

// useEditableData.ts - 複数フックを組み合わせたフック
import { useApi } from './useApi';
import { useForm } from './useForm';
import { useLocalStorage } from './useLocalStorage';

function useEditableData(apiUrl: string, storageKey: string) {
  const { data, loading, error, refetch } = useApi(apiUrl);
  const [draftData, setDraftData] = useLocalStorage(storageKey, null);

  // フォーム初期値をAPIデータから設定
  const formConfig = {
    name: {
      initialValue: data?.name || draftData?.name || '',
      rules: [requiredRule],
    },
    email: {
      initialValue: data?.email || draftData?.email || '',
      rules: [requiredRule, emailRule],
    },
  };

  const { fields, handleSubmit, getFieldProps } = useForm(formConfig);

  const handleSaveDraft = useCallback(() => {
    const draftValues = Object.entries(fields).reduce(
      (acc, [key, field]) => ({
        ...acc,
        [key]: field.value,
      }),
      {}
    );
    setDraftData(draftValues);
  }, [fields, setDraftData]);

  return {
    loading,
    error,
    fields,
    getFieldProps,
    handleSubmit,
    handleSaveDraft,
    refetch,
  };
}

カスタムフック内での非同期処理

useEffectと組み合わせて、マウント時、値変更時などの特定タイミングで非同期処理を実行する必要があります。ここではデバウンス付きの実装例を示します。

// useDebounce.ts - デバウンス処理を含むフック
import { useState, useEffect } from 'react';

function useDebounce(value: T, delay: number = 500): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

// 使用例: 検索入力フィールド
function SearchUsers() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 300);
  
  const { data: results } = useApi(
    `https://api.example.com/users/search?q=${debouncedSearchTerm}`,
    { immediate: !!debouncedSearchTerm }
  );

  return (
    
setSearchTerm(e.target.value)} placeholder=\"ユーザーを検索\" />
    {results?.map(user => (
  • {user.name}
  • ))}
); }

注意点|実務で気をつけるべきこと

1. 無限ループの防止

useEffectの依存配列を間違えると、無限ループが発生します。特に、フック内でオブジェクトや配列を作成してそれを依存配列に入れる場合は注意が必要です。

// ❌ 悪い例:毎回新しいオブジェクトが作成されてループする
function BadExample() {
  const config = { timeout: 10000 }; // 毎回新しいオブジェクト
  const { data } = useApi('/api/data', config); // configが依存配列に入ると無限ループ
}

// ✅ 良い例:useMemoで依存配列の参照を安定化
function GoodExample() {
  const config = useMemo(() => ({ timeout: 10000 }), []);
  const { data } = useApi('/api/data', config);
}

2. メモリリーク対策

setTimeoutやイベントリスナーなどのリソースは、useEffectのクリーンアップ関数で必ず破棄してください。

// ❌ メモリリークの原因
useEffect(() => {
  const timer = setInterval(() => {
    // 何か処理
  }, 1000);
  // クリーンアップがないのでタイマーが残る
}, []);

// ✅ 正しい実装
useEffect(() => {
  const timer = setInterval(() => {
    // 何か処理
  }, 1000);

  return () => clearInterval(timer); // クリーンアップ関数で破棄
}, []);

3. 型安全性

TypeScriptを使用している場合、ジェネリック型を活用してカスタムフックの型安全性を確保します。

// ✅ ジェネリック型を使った型安全なフック
interface ApiResponse {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

function useApi(url: string): ApiResponse {
  // 実装...
}

// 使用時に型が自動推論される
const { data: users } = useApi('/api/users');
// usersの型はUser[] | nullで推論される

4. 複数呼び出しの最適化

同じURLのAPIを複数コンポーネントで呼び出す場合、キャッシュの仕組みが必須です。前述のuseApiの例ではグローバルキャッシュを使いましたが、本格的なプロジェクトではReactQueryやSWRなどのライブラリの導入を検討してください。

5. SSR環境での配慮

Next.jsなどでSSRを使用する場合、ローカルストレージやwindowオブジェクトへのアクセスはクライアント側でのみ実行する必要があります。

// ✅ SSR対応のローカルストレージフック
function useLocalStorageSSR(key: string, initialValue: T) {
  const [isClient, setIsClient] = useState(false);
  const [storedValue, setStoredValue] = useState(initialValue);

  useEffect(() => {
    setIsClient(true);
    try {
      const item = window.localStorage.getItem(key);
      if (item) {
        setStoredValue(JSON.parse(item));
      }
    } catch (error) {
      console.error(error);
    }
  }, [key]);

  const setValue = useCallback((value: T) => {
    if (!isClient) return;
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
      setStoredValue(value);
    } catch (error) {
      console.error(error);
    }
  }, [isClient, key]);

  return [storedValue, setValue] as const;
}

まとめ

カスタムフックは、Reactコンポーネント間でロジックを再利用するための強力な仕組みです。教科書的なサンプルではなく、実務で本当に必要となるAPI通信、フォーム管理、ローカルストレージ同期などの実装パターンを習得することで、保守性が高く再利用可能なコンポーネント設計が実現できます。

重要なのは以下の点です:

  • API通信フック:キャッシング、リトライ、タイムアウト処理を組み込む
  • フォーム管理フック:バリデーション、エラー表示、送信状態を一元管理
  • ストレージ同期フック:JSON化、エラーハンドリング、タブ間同期に対応
  • メモリリーク防止:クリーンアップ関数でリソースを確実に破棄
  • 型安全性:TypeScriptで組み込んで、開発効率と品質を向上させる

これらのパターンを組み合わせることで、スケーラブルで保守性の高いReactアプリケーションを構築できます。ただし、複雑な状態管理が必要な大規模プロジェクトではReduxやReactQueryなどの専門ライブラリの導入も検討してください。

タイトルとURLをコピーしました