サイトアイコン 【TechGrowUp】

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

はじめに

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;
}

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

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 };
}

ポイント解説

事例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 内で 副作用をレンダーフェーズに書くとバグの温床 になります。

スケルトン 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 パターン

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

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

落とし穴と対策一覧

症状原因対処
無限ループ依存配列にオブジェクトリテラル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 プロジェクトを 再利用性が高く、バグが少なく、パフォーマンスに優れた コードベースへ進化させてください。

モバイルバージョンを終了