Custom Hooks完全ガイド──Reactでロジックを再利用しコンポーネントを超DRY

React

はじめに

React 公式チュートリアルでも強調される「コンポーネントは UI を、Hooks はロジックを記述する」という原則。しかし現場のコードベースでは、似たような副作用や状態初期化ロジックを複数コンポーネント間でコピペしてしまうことが珍しくありません。Custom Hook を導入すると、共通処理を 1 箇所でテスト・保守でき、UI コンポーネントは宣言的に保たれます。本記事では公式ドキュメント https://react.dev/learn/reusing-logic-with-custom-hooks をベースに、基礎から高度なパターン、テスト、パフォーマンス、型安全まで徹底解説します。

Custom Hooks の基本構文

import { useState, useEffect } from 'react';

export function useClock() {
  const [time, setTime] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(id);
  }, []);

  return time;
}
  • 関数名use から始める
  • ビルトイン Hook は通常どおり使用可能
  • 依存配列は呼び出し元コンポーネントではなく、Hook 内部で完結

よくあるアンチパターン: カスタムフックの中で条件付きフック呼び出し

function useWrong(flag) {
  if (flag) {
    // これはルール違反: フックはトップレベルで呼ぶ必要がある
    useEffect(() => console.log('Bad'), []);
  }
}

React のルールオブフックスに違反し、レンダー順序が崩れる可能性があります。必ずトップレベルで呼び、条件分岐は内部で処理を早期リターンするなどで対応しましょう.

事例1: API フェッチロジックの共通化

ネットワーク通信は多くの画面で発生するため、抽象化すると保守負荷が激減します。

import { useState, useEffect } from 'react';

export function useFetch(url, options) {
  const [status, setStatus] = useState('idle');  // idle | loading | success | error
  const [data, setData]   = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;
    const controller = new AbortController();

    async function start() {
      setStatus('loading');
      try {
        const res = await fetch(url, { ...options, signal: controller.signal });
        if (!res.ok) throw new Error(res.statusText);
        const json = await res.json();
        if (!ignore) {
          setData(json);
          setStatus('success');
        }
      } catch (e) {
        if (!ignore) {
          setError(e);
          setStatus('error');
        }
      }
    }
    start();

    return () => {
      ignore = true;
      controller.abort();
    };
  }, [url, JSON.stringify(options)]);

  return { status, data, error };
}

ポイント解説

  • AbortController でコンポーネントのアンマウント時にリクエストを中断
  • 依存配列に options がオブジェクトの場合は JSON.stringify で比較簡略化
  • status を文字列列挙にすることで UI が状態ごとに切り替えやすくなる

事例2: フォーム入力とバリデーション

React Hook Form や Formik を使わずに軽量に済ませたい場合、以下のような useForm フックを自作できます。

export function useForm(initial) {
  const [values, setValues] = useState(initial);
  const [errors, setErrors] = useState({});

  const onChange = (e) => {
    const { name, value } = e.target;
    setValues(v => ({ ...v, [name]: value }));
  };

  const validate = (rules) => {
    const newErrors = {};
    for (const key in rules) {
      const error = rules[key](values[key]);
      if (error) newErrors[key] = error;
    }
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const reset = () => setValues(initial);

  return { values, errors, onChange, validate, reset };
}

使用例

function Signup() {
  const { values, errors, onChange, validate } = useForm({ email: '', pw: '' });

  const handleSubmit = () => {
    if (validate({
      email: v => /@/.test(v) ? '' : 'メールが不正',
      pw: v => v.length >= 8 ? '' : '8文字以上必要'
    })) {
      // 送信
    }
  };
  return (/* JSX */);
}

パフォーマンスとメモ化のベストプラクティス

Custom Hook 内で useMemouseCallback を使う場面:

  1. 計算コストが高いロジックをキャッシュ
  2. 子に渡すコールバックを安定参照に
  3. オブジェクトや配列を返す場合—参照が変わると下位ツリーが再レンダー
function usePagination(total, perPage) {
  const pageCount = useMemo(() => Math.ceil(total / perPage), [total, perPage]);
  return pageCount;
}

TypeScriptとの組み合わせ

Custom Hook の戻り値がオブジェクトの場合、ジェネリクスで型の再利用性を高められます。

function useToggle<S extends string>(on: S, off: S) {
  const [state, setState] = useState<S>(off);
  const toggle = () => setState(s => s === on ? off : on);
  return { state, toggle };
}

const { state } = useToggle<'on' | 'off'>('on', 'off');

テスト戦略

@testing-library/react-hooks を用いると、Hook 単体をテストできます。

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

test('カウンタが増える', () => {
  const { result } = renderHook(() => useCounter());
  act(() => result.current.inc());
  expect(result.current.count).toBe(1);
});

高度: イベントリスナーを扱う useEventListener フック

DOM イベントを登録・解除するロジックはほぼすべての SPA で必要です。以下の汎用フックをプロジェクトに追加すると、スクロール監視やキー入力検知を簡単に組み込めます。

import { useEffect, useRef } from 'react';

export function useEventListener(type, handler, target = window) {
  const saved = useRef();

  useEffect(() => {
    saved.current = handler;
  }, [handler]);

  useEffect(() => {
    const el = target?.current || target;
    if (!el?.addEventListener) return;
    const listener = (e) => saved.current(e);
    el.addEventListener(type, listener);
    return () => el.removeEventListener(type, listener);
  }, [type, target]);
}

利用例: Escape キーでモーダルを閉じる

function Modal({ onClose }) {
  useEventListener('keydown', e => {
    if (e.key === 'Escape') onClose();
  });
  /* ... */
}

React 18 Concurrent Rendering と Custom Hooks

中断と再実行

並列レンダーではコンポーネントが「途中で止まって再開」される可能性があります。Custom Hook 内で 副作用をレンダーフェーズに書くとバグの温床 になります。

  • NG: useMemo(() => performSideEffect(), [])
  • OK : useEffect 内で副作用を実行し、クリーンアップを返す

スケルトン UI の設計

useSuspenseQuery などデータフェッチ Hook を利用するときは、サスペンス発火タイミングを想定しローディングプレースホルダを計画的に配置しましょう。

パフォーマンス計測: React Profiler で Hook のコストを可視化

  1. React DevTools の Profiler タブを開き「Record」
  2. 画面操作を実行
  3. Flamegraph または Ranked view で再レンダー時間を検査
  4. Custom Hook 導入前後で差分を比較

重い計算が Commit フェーズに長く表示される場合は useMemo、頻繁なレンダーが Render フェーズに点在する場合は useCallbackReact.memo との併用を検討します。

他ライブラリの Custom Hook パターン

  • TanStack Query: useQuery, useMutation
  • Redux Toolkit: useSelector, useDispatch
  • Zustand: useStore(selector)

カスタム Hook のドキュメントとディスカバビリティ

大規模チームでは「どんな Hook があるか分からない」問題が発生します。

  • 命名規約:機能+動詞 (useFetchUser, useDebouncedValue)
  • Storybook Docs: Hook 用ストーリを作り動作を可視化
  • JSDoc/TSDoc と例コードを README に添付

落とし穴と対策一覧

症状原因対処
無限ループ依存配列にオブジェクトリテラルuseMemo で固定 or JSON.stringify
stale data依存配列抜け漏れESLint exhaustive-deps
メモリリークuseEffect 内でクリーンアップ忘れ必ず return で解除
フックのネスト深すぎ抽象化不足/責務過多Hook を小さく分割

いまさら聞けない React ルールオブフックス

  1. トップレベルでのみ呼ぶ
  2. 関数コンポーネント or Custom Hook 内で呼ぶ

エラーハンドリング: useSafeAsync フック

function useSafeAsync(fn, deps = []) {
  const mounted = useRef(true);
  useEffect(() => {
    mounted.current = true;
    fn().catch(console.error);
    return () => (mounted.current = false);
  }, deps);
  const safeSet = updater => {
    if (mounted.current) updater();
  };
  return safeSet;
}

フックファクトリパターン

function createUseApi(base) {
  return function useApi(path) {
    return useFetch(base + path);
  };
}
const useGitHub = createUseApi('https://api.github.com');

まとめ

この記事では Custom Hook の意義から実装、型安全、テスト、パフォーマンス、外部 API 連携、アクセシビリティまで幅広く網羅しました。これらのノウハウを活かし、あなたの React プロジェクトを 再利用性が高く、バグが少なく、パフォーマンスに優れた コードベースへ進化させてください。

コメント