React Custom Hookの実務的な使い方|実装パターンと業務での活用事例

React / Next.js

React Custom Hookの実務的な使い方|実装パターンと業務での活用事例

React開発において、Custom Hook(カスタムフック)は非常に強力なツールです。しかし、教科書的な例だけでは実務でどう活用すればよいか、判断がつきにくいものです。本記事では、実際のプロジェクトで使用されるCustom Hookの実装パターンと、その活用方法を紹介します。

1. Custom Hookの簡易的な解説

React Custom Hookは、React Hooksの機能を組み合わせて、ロジックを再利用可能な関数にしたものです。useState、useEffect、useContextなどのHooksを内部で使用し、コンポーネント間でロジックを共有できます。

基本的な特徴は以下の通りです:

  • 「use」で始まる関数として定義する
  • 内部で他のHooksを呼び出せる
  • ステートフルなロジックをコンポーネントから分離できる
  • 複数のコンポーネントで同じロジックを再利用できる

2. 業務でのユースケース

実務開発では、以下のようなシーンでCustom Hookが活躍します:

ユースケース1:フォーム管理の複雑さへの対応

複数のフォームフィールドを持つページでは、各フィールドの入力値管理、バリデーション、送信処理などが複雑になります。これらをCustom Hookに集約することで、コンポーネントのコードがシンプルになります。

ユースケース2:API通信のロジック共有

複数のコンポーネントから同じAPIエンドポイントへアクセスする場合、ローディング状態やエラーハンドリングをCustom Hookで統一できます。

ユースケース3:ローカルストレージの操作

ユーザーの設定やアプリケーションの状態をローカルストレージに保存・読み込みする処理は、複数箇所で必要になることが多いです。

3. 実装コード|実務で使える4つのパターン

パターン1:フォーム管理Hook

以下は、実務でよく使われるフォーム管理のCustom Hookです。入力値の管理、バリデーション、リセット機能が含まれています。

// useForm.ts
import { useState, useCallback } from 'react';

interface FormErrors {
  [key: string]: string;
}

interface UseFormOptions {
  initialValues: Record<string, any>;
  onSubmit: (values: Record<string, any>) => Promise<void> | void;
  validate?: (values: Record<string, any>) => FormErrors;
}

export const useForm = ({
  initialValues,
  onSubmit,
  validate,
}: UseFormOptions) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState<FormErrors>({});
  const [touched, setTouched] = useState<Record<string, boolean>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
      const { name, value, type } = e.target;
      const fieldValue = type === 'checkbox' ? (e.target as HTMLInputElement).checked : value;
      
      setValues((prev) => ({
        ...prev,
        [name]: fieldValue,
      }));

      // リアルタイムバリデーション
      if (touched[name] && validate) {
        const newErrors = validate({ ...values, [name]: fieldValue });
        setErrors((prev) => ({
          ...prev,
          [name]: newErrors[name] || '',
        }));
      }
    },
    [values, touched, validate]
  );

  const handleBlur = useCallback(
    (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
      const { name } = e.target;
      setTouched((prev) => ({
        ...prev,
        [name]: true,
      }));

      if (validate) {
        const newErrors = validate(values);
        setErrors((prev) => ({
          ...prev,
          [name]: newErrors[name] || '',
        }));
      }
    },
    [values, validate]
  );

  const handleSubmit = useCallback(
    async (e: React.FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      
      if (validate) {
        const newErrors = validate(values);
        setErrors(newErrors);
        if (Object.keys(newErrors).length > 0) {
          return;
        }
      }

      setIsSubmitting(true);
      try {
        await onSubmit(values);
      } catch (error) {
        console.error('Form submission error:', error);
      } finally {
        setIsSubmitting(false);
      }
    },
    [values, validate, onSubmit]
  );

  const resetForm = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);

  return {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
    resetForm,
    setValues,
  };
};

使用例:

// LoginForm.tsx
import { useForm } from './useForm';

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

const LoginForm = () => {
  const validate = (values: LoginFormValues) => {
    const errors: FormErrors = {};
    
    if (!values.email) {
      errors.email = 'メールアドレスは必須です';
    } else if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(values.email)) {
      errors.email = '有効なメールアドレスを入力してください';
    }
    
    if (!values.password) {
      errors.password = 'パスワードは必須です';
    } else if (values.password.length < 8) {
      errors.password = 'パスワードは8文字以上である必要があります';
    }
    
    return errors;
  };

  const handleLoginSubmit = async (values: LoginFormValues) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values),
    });
    
    if (!response.ok) {
      throw new Error('ログインに失敗しました');
    }
  };

  const form = useForm({
    initialValues: { email: '', password: '' },
    validate,
    onSubmit: handleLoginSubmit,
  });

  return (
    <form onSubmit={form.handleSubmit}>
      <div>
        <input
          type=\"email\"
          name=\"email\"
          value={form.values.email}
          onChange={form.handleChange}
          onBlur={form.handleBlur}
          placeholder=\"メールアドレス\"
        />
        {form.touched.email && form.errors.email && (
          <span style={{ color: 'red' }}>{form.errors.email}</span>
        )}
      </div>
      <div>
        <input
          type=\"password\"
          name=\"password\"
          value={form.values.password}
          onChange={form.handleChange}
          onBlur={form.handleBlur}
          placeholder=\"パスワード\"
        />
        {form.touched.password && form.errors.password && (
          <span style={{ color: 'red' }}>{form.errors.password}</span>
        )}
      </div>
      <button type=\"submit\" disabled={form.isSubmitting}>
        {form.isSubmitting ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  );
};

パターン2:API通信Hook(useFetch)

実務では、複数のコンポーネントから異なるAPIエンドポイントへアクセスします。このHookで、データ取得、ローディング、エラーを統一的に管理できます。

// useFetch.ts
import { useState, useEffect, useCallback } from 'react';

interface UseFetchOptions {
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
  headers?: Record<string, string>;
  body?: Record<string, any>;
  skip?: boolean;
}

interface UseFetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

export const useFetch = <T = any,>(
  url: string,
  options: UseFetchOptions = {}
): UseFetchState<T> & { refetch: () => Promise<void> } => {
  const { method = 'GET', headers = {}, body, skip = false } = options;
  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    loading: !skip,
    error: null,
  });

  const fetchData = useCallback(async () => {
    if (skip) return;

    setState((prev) => ({ ...prev, loading: true, error: null }));

    try {
      const token = localStorage.getItem('authToken');
      const response = await fetch(url, {
        method,
        headers: {
          'Content-Type': 'application/json',
          ...(token && { Authorization: `Bearer ${token}` }),
          ...headers,
        },
        body: body ? JSON.stringify(body) : undefined,
      });

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

      const result = (await response.json()) as T;
      setState({ data: result, loading: false, error: null });
    } catch (err) {
      const error = err instanceof Error ? err : new Error('Unknown error');
      setState((prev) => ({ ...prev, error, loading: false }));
    }
  }, [url, method, headers, body, skip]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  const refetch = useCallback(async () => {
    await fetchData();
  }, [fetchData]);

  return { ...state, refetch };
};

使用例:

// UserList.tsx
import { useFetch } from './useFetch';

interface User {
  id: number;
  name: string;
  email: string;
}

const UserList = () => {
  const { data: users, loading, error, refetch } = useFetch<User[]>('/api/users');

  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error.message}</div>;

  return (
    <div>
      <button onClick={refetch}>更新</button>
      <ul>
        {users?.map((user) => (
          <li key={user.id}>
            {user.name} ({user.email})
          </li>
        ))}
      </ul>
    </div>
  );
};

パターン3:ローカルストレージHook(useLocalStorage)

ユーザーの設定やアプリケーション状態の永続化に使います。

// useLocalStorage.ts
import { useState, useCallback, useEffect } from 'react';

export const useLocalStorage = <T,>(
  key: string,
  initialValue: T
): [T, (value: T) => void, () => void] => {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key \"${key}\":`, error);
      return initialValue;
    }
  });

  const setValue = useCallback(
    (value: T) => {
      try {
        setStoredValue(value);
        window.localStorage.setItem(key, JSON.stringify(value));
      } catch (error) {
        console.error(`Error setting localStorage key \"${key}\":`, error);
      }
    },
    [key]
  );

  const removeValue = useCallback(() => {
    try {
      setStoredValue(initialValue);
      window.localStorage.removeItem(key);
    } catch (error) {
      console.error(`Error removing localStorage key \"${key}\":`, error);
    }
  }, [key, initialValue]);

  // 別タブでの変更を検知
  useEffect(() => {
    const handleStorageChange = (e: StorageEvent) => {
      if (e.key === key && e.newValue) {
        setStoredValue(JSON.parse(e.newValue));
      }
    };

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

  return [storedValue, setValue, removeValue];
};

使用例:

// ThemeToggle.tsx
import { useLocalStorage } from './useLocalStorage';

type Theme = 'light' | 'dark';

const ThemeToggle = () => {
  const [theme, setTheme] = useLocalStorage<Theme>('app-theme', 'light');

  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };

  return (
    <div style={{
      background: theme === 'light' ? '#fff' : '#333',
      color: theme === 'light' ? '#000' : '#fff',
      padding: '20px',
    }}>
      <p>現在のテーマ: {theme}</p>
      <button onClick={toggleTheme}>テーマを切り替え</button>
    </div>
  );
};

パターン4:デバウンス付きHook(useDebounce)

検索ボックスなどで、入力値の変化に対して遅延実行が必要な場合に使います。

// useDebounce.ts
import { useState, useEffect } from 'react';

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

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

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

  return debouncedValue;
};

使用例:

// ProductSearch.tsx
import { useState } from 'react';
import { useDebounce } from './useDebounce';
import { useFetch } from './useFetch';

interface SearchResult {
  id: number;
  name: string;
}

const ProductSearch = () => {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 300);

  const { data: results, loading } = useFetch<SearchResult[]>(
    `/api/products/search?q=${debouncedSearchTerm}`,
    { skip: !debouncedSearchTerm }
  );

  return (
    <div>
      <input
        type=\"text\"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder=\"商品を検索...\"
      />
      {loading && <p>検索中...</p>}
      <ul>
        {results?.map((result) => (
          <li key={result.id}>{result.name}</li>
        ))}
      </ul>
    </div>
  );
};

4. よくある応用パターン

複合Hook:useFormWithAPI

フォーム送信とAPI通信を組み合わせたHookです。実務では非常に頻繁に使われます。

// useFormWithAPI.ts
import { useState, useCallback } from 'react';
import { useForm } from './useForm';

interface UseFormWithAPIOptions {
  initialValues: Record<string, any>;
  validate?: (values: Record<string, any>) => Record<string, string>;
  apiEndpoint: string;
  method?: 'POST' | 'PUT' | 'PATCH';
  onSuccess?: () => void;
}

export const useFormWithAPI = ({
  initialValues,
  validate,
  apiEndpoint,
  method = 'POST',
  onSuccess,
}: UseFormWithAPIOptions) => {
  const [apiError, setApiError] = useState<string | null>(null);

  const onSubmit = useCallback(
    async (values: Record<string, any>) => {
      setApiError(null);

      try {
        const token = localStorage.getItem('authToken');
        const response = await fetch(apiEndpoint, {
          method,
          headers: {
            'Content-Type': 'application/json',
            ...(token && { Authorization: `Bearer ${token}` }),
          },
          body: JSON.stringify(values),
        });

        if (!response.ok) {
          const errorData = await response.json();
          throw new Error(errorData.message || 'APIリクエストに失敗しました');
        }

        onSuccess?.();
      } catch (error) {
        const message = error instanceof Error ? error.message : 'Unknown error';
        setApiError(message);
        throw error;
      }
    },
    [apiEndpoint, method, onSuccess]
  );

  const form = useForm({
    initialValues,
    validate,
    onSubmit,
  });

  return {
    ...form,
    apiError,
    setApiError,
  };
};

ページネーションHook(usePagination)

リスト表示の際にページング機能が必要な場合が多いです。

// usePagination.ts
import { useState, useMemo } from 'react';

interface UsePaginationOptions {
  items: any[];
  itemsPerPage: number;
}

export const usePagination = ({ items, itemsPerPage }: UsePaginationOptions) => {
  const [currentPage, setCurrentPage] = useState(1);

  const { currentItems, totalPages } = useMemo(() => {
    const total = Math.ceil(items.length / itemsPerPage);
    const startIndex = (currentPage - 1) * itemsPerPage;
    const endIndex = startIndex + itemsPerPage;
    const current = items.slice(startIndex, endIndex);

    return {
      currentItems: current,
      totalPages: total,
    };
  }, [items, itemsPerPage, currentPage]);

  const goToPage = (page: number) => {
    const pageNumber = Math.max(1, Math.min(page, totalPages));
    setCurrentPage(pageNumber);
  };

  return {
    currentItems,
    currentPage,
    totalPages,
    goToPage,
    nextPage: () => goToPage(currentPage + 1),
    prevPage: () => goToPage(currentPage - 1),
  };
};

5. 実務での注意点

注意点1:無限ループの防止

useEffectの依存配列を正しく設定しないと、無限ループが発生します。特に、フェッチ関数をuseCallbackでメモ化することが重要です。

// 悪い例
const MyComponent = () => {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetch('/api/data').then(res => res.json()).then(setData);
  }, []); // 空の依存配列は問題ありませんが...
  
  return <div>{JSON.stringify(data)}</div>;
};

// 良い例
const MyComponent = () => {
  const [data, setData] = useState(null);
  
  const fetchData = useCallback(async () => {
    const response = await fetch('/api/data');
    const result = await response.json();
    setData(result);
  }, []);
  
  useEffect(() => {
    fetchData();
  }, [fetchData]);
  
  return <div>{JSON.stringify(data)}</div>;
};

注意点2:メモリリークの防止

コンポーネントがアンマウントされる際に、タイマーやリスナーをクリーンアップする必要があります。

// useOnlineStatus.ts
import { useState, useEffect } from 'react';

export const useOnlineStatus = () => {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    // クリーンアップ関数でリスナーを削除
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
};

注意点3:キャッシング戦略

同じデータを何度も取得するのは非効率です。実務では、キャッシング機能をHookに組み込むことが一般的です。

// useFetchWithCache.ts
import { useState, useCallback } from 'react';

const cache = new Map<string, { data: any; timestamp: number }>();
const CACHE_DURATION = 5 * 60 * 1000; // 5分

export const useFetchWithCache = <T = any,>(url: string) => {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const fetchData = useCallback(async (forceRefresh = false) => {
    // キャッシュが有効か確認
    if (!forceRefresh && cache.has(url)) {
      const cached = cache.get(url)!;
      if (Date.now() - cached.timestamp < CACHE_DURATION) {
        setData(cached.data);
        return;
      }
    }

    setLoading(true);
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error('Fetch failed');
      const result = (await response.json()) as T;
      
      // キャッシュに保存
      cache.set(url, { data: result, timestamp: Date.now() });
      setData(result);
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'));
    } finally {
      setLoading(false);
    }
  }, [url]);

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

注意点4:TypeScriptの型安全性

Custom Hookを使う際は、ジェネリクスを活用して型安全性を確保しましょう。

// 型安全なHookの例
interface ApiResponse<T> {
  success: boolean;
  data: T;
  message?: string;
}

export const useTypedFetch = <T,>(url: string) => {
  // ...実装
  return {
    data: null as T | null,
    loading: false,
    error: null as Error | null,
  };
};

// 使用時に型が自動推論される
const MyComponent = () => {
  const { data } = useTypedFetch<ApiResponse<{ id: number; name: string }>>('/api/user');
  // dataの型は ApiResponse<{ id: number; name: string }> | null として推論される
};

注意点5:テスタビリティ

Custom Hookのテストは、@testing-library/reactの renderHook を使用します。

// useForm.test.ts
import { renderHook, act } from '@testing-library/react';
import { useForm } from './useForm';

describe('useForm', () => {
  it('フォームの入力値が更新される', () => {
    const { result } = renderHook(() =>
      useForm({
        initialValues: { email: '', password: '' },
        onSubmit: async () => {},
      })
    );

    const event = {
      target: { name: 'email', value: 'test@example.com' },
    } as unknown as React.ChangeEvent<HTMLInputElement>;

    act(() => {
      result.current.handleChange(event);
    });

    expect(result.current.values.email).toBe('test@example.com');
  });
});

6. まとめ

React Custom Hookは、適切に使用することで、コードの再利用性と保守性を大幅に向上させます。実務開発では、フォーム管理、API通信、ローカルストレージの操作など、繰り返される処理をCustom Hookで統一することが非常に重要です。

本記事で紹介した4つの基本パターン(useForm、useFetch、useLocalStorage、useDebounce)と、それらの組み合わせ方を理解することで、ほとんどの実務シナリオに対応できます。

重要なのは、以下の点を常に意識することです:

  • 無限ループ防止:useCallbackとuseEffectの依存配列を正確に設定する
  • メモリリーク防止:クリーンアップ関数を必ず実装する
  • 型安全性:TypeScriptのジェネリクスを活用する
  • パフォーマンス:不要な再計算やレンダリングを避ける
  • テスタビリティ:Hookのロジックは独立してテスト可能にする

これらを意識して、適切なCustom Hookを設計・実装することで、スケーラブルで保守しやすいReactアプリケーションを構築できるでしょう。

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