はじめに
React 公式チュートリアルでも強調される「コンポーネントは UI を、Hooks はロジックを記述する」という原則。しかし現場のコードベースでは、似たような副作用や状態初期化ロジックを複数コンポーネント間でコピペしてしまうことが珍しくありません。Custom Hook を導入すると、共通処理を 1 箇所でテスト・保守でき、UI コンポーネントは宣言的に保たれます。本記事では公式ドキュメント https://react.dev/learn/reusing-logic-with-custom-hooks をベースに、基礎から高度なパターン、テスト、パフォーマンス、型安全まで徹底解説します。
- Custom Hooks の基本構文
- よくあるアンチパターン: カスタムフックの中で条件付きフック呼び出し
- 事例1: API フェッチロジックの共通化
- 事例2: フォーム入力とバリデーション
- パフォーマンスとメモ化のベストプラクティス
- TypeScriptとの組み合わせ
- テスト戦略
- 高度: イベントリスナーを扱う useEventListener フック
- React 18 Concurrent Rendering と Custom Hooks
- パフォーマンス計測: React Profiler で Hook のコストを可視化
- 他ライブラリの Custom Hook パターン
- カスタム Hook のドキュメントとディスカバビリティ
- 落とし穴と対策一覧
- いまさら聞けない React ルールオブフックス
- エラーハンドリング: useSafeAsync フック
- フックファクトリパターン
- まとめ
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 内で useMemo
や useCallback
を使う場面:
- 計算コストが高いロジックをキャッシュ
- 子に渡すコールバックを安定参照に
- オブジェクトや配列を返す場合—参照が変わると下位ツリーが再レンダー
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 のコストを可視化
- React DevTools の Profiler タブを開き「Record」
- 画面操作を実行
- Flamegraph または Ranked view で再レンダー時間を検査
- Custom Hook 導入前後で差分を比較
重い計算が Commit
フェーズに長く表示される場合は useMemo
、頻繁なレンダーが Render
フェーズに点在する場合は useCallback
や React.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 ルールオブフックス
- トップレベルでのみ呼ぶ
- 関数コンポーネント 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 プロジェクトを 再利用性が高く、バグが少なく、パフォーマンスに優れた コードベースへ進化させてください。
コメント