お疲れ。最近、君が書いたカスタムフックのコードをいくつかレビューさせてもらったんだけどさ……いや、動くには動くんだよ。動くにはね。ただ、再レンダリングの嵐が起きたり、古いステートを参照し続ける「古き良きバグ」が潜んでいたりして、夜も眠れなくなったんだ。
特に、カスタムフックの引数設計と `useEffect` や `useCallback` の依存配列(dependencies)の扱い方。ここ、React開発者にとっての「鬼門」なんだよね。
今日は、なぜ君の書いたカスタムフックが暴走しがちなのか、そしてブラウザの裏側でReactがどう動いているのかを紐解きながら、明日から即戦力で使える「イケてるカスタムフックの設計論」を叩き込んでやろうと思う。コーヒーでも飲みながら聞いてくれ。
—
1. なぜカスタムフックの依存関係管理は泥沼化するのか?
まず大前提として、Reactのフックの本質を思い出してほしい。
Reactは、レンダーのたびに上から下へ関数を再実行するだけの「ただのJavaScriptの関数」だ。その中で、前回のレンダーの記憶を保持するために `useState` や `useRef` があり、外部の世界(APIやDOMなど)と同期させるために `useEffect` がある。
カスタムフックというのは、この「記憶と同期のロジック」をカプセル化するための強力な武器だ。しかし、ここで多くのエンジニアが躓く。
「すべてを依存配列に入れろ」という呪縛の正体
Linter(`eslint-plugin-react-hooks`)が「おい、依存関係が足りないぞ!」と赤線を引いて怒ってくるアレだ。思考停止で全部の変数を依存配列に突っ込むと、今度は「無限ループの地獄」や、「関数が毎回生成されることによる不要な子コンポーネントの再レンダリング」という別のモンスターを召喚することになる。
ブラウザの裏側で何が起きているか?
Reactは、依存配列の要素を `Object.is` で浅い比較(Shallow Comparison)している。もし、親から渡されたオブジェクトや、カスタムフック内で定義された無名関数が毎レンダーごとに新しい参照(メモリ上の別のアドレス)として生成されていたらどうなるか? Reactは「あ、中身が変わったな(本当は中身は同じかもしれないのに)」と勘違いし、エフェクトを毎回再実行してしまう。これがパフォーマンス劣化の根本原因だ。
—
2. 柔軟性と堅牢性を両立する引数設計のプラクティス
じゃあ、どう設計すればいいのか?
ここで、実務で使える具体的なテーマとして、「APIからデータを取得しつつ、ポーリング(定期取得)も制御できるカスタムフック」を例に考えてみよう。
ダメな設計の典型例はこれだ:
- 引数がプリミティブ値ではなく、巨大なオブジェクトの塊になっている。
- コールバック関数を引数に取るが、それがメモ化されていないため、フック内部の `useEffect` が毎レンダーで発火する。
実務で使える!クリーンなカスタムフックの実装例
以下のコードを見てほしい。これは、引数の設計と依存関係の管理を極限まで洗練させた実務レベルのサンプルだ。そのままコピーしてプロジェクトで使えるように作り込んである。
import { useState, useEffect, useCallback, useRef } from ‘react’;
// 1. 引数は「値のプリミティブ化」または「プレーンな設定オブジェクト」にする
type UsePollingOptions
fetcher: () => Promise
intervalMs?: number;
enabled?: boolean;
onSuccess?: (data: T) => void;
onError?: (error: unknown) => void;
};
type UsePollingResult
data: T | null;
error: unknown;
isLoading: boolean;
execute: () => Promise
};
export function usePolling
fetcher,
intervalMs = 5000,
enabled = true,
onSuccess,
onError,
}: UsePollingOptions
const [data, setData] = useState
const [error, setError] = useState
const [isLoading, setIsLoading] = useState
// 2. コールバック関数やfetcherの「最新版」をRefで保持する
// これにより、依存配列にfetcherを入れる必要がなくなり、不要な再実行を防げる
const fetcherRef = useRef(fetcher);
const onSuccessRef = useRef(onSuccess);
const onErrorRef = useRef(onError);
useEffect(() => {
fetcherRef.current = fetcher;
onSuccessRef.current = onSuccess;
onErrorRef.current = onError;
});
// 3. データの取得ロジックをuseCallbackでメモ化
const execute = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await fetcherRef.current();
setData(result);
// ref経由で最新のコールバックを安全に呼び出す
onSuccessRef.current?.(result);
} catch (err) {
setError(err);
onErrorRef.current?.(err);
} finally {
setIsLoading(false);
}
}, []); // 依存配列が空なので、この関数インスタンスは再生成されない
// 4. ポーリングとライフサイクルの管理
useEffect(() => {
if (!enabled) return;
// 初回実行
execute();
// 定期実行の設定
const timerId = setInterval(() => {
execute();
}, intervalMs);
// クリーンアップ関数(メモリリーク防止とタイマーのリセット)
return () => {
clearInterval(timerId);
};
}, [enabled, intervalMs, execute]);
return { data, error, isLoading, execute };
}
—
3. コードの解説:なぜこの設計が「プロの技」なのか?
このコードには、シニアが現場で培ってきた泥臭いノウハウが詰まっている。ポイントを3つに絞って解説しよう。
① `useRef` を使った「最新値の逃がし(Ref Pattern)」
これが今回のハイライトだ。
カスタムフックの利用者が `usePolling` に渡す `fetcher` 関数は、大抵の場合、コンポーネントのレンダリングごとに新しく作られる(例:コンポーネント内でインライン定義されたアロー関数)。
もし、これをそのまま `useEffect` の依存配列に入れてしまうと、「データを取得する = コンポーネントの状態が更新される = 再レンダリング = `fetcher` のアドレスが変わる = `useEffect` が再発火して無限ループ」という最悪のコンボが完成する。
それを防ぐために、`useRef` を使って「関数の参照だけを常に最新にアップデートしつつ、依存配列からは外す」というテクニックを使っている。これにより、親コンポーネント側で `useCallback` による厳密なメモ化を強制しなくても、フック側で安全に動作を担保できる(=DX(開発者体験)が爆上がりする)。
② 依存配列のミニマライズ
このフックの `useEffect` が依存しているのは `[enabled, intervalMs, execute]` の3つだけだ。
`execute` 自体は `useCallback` で完全にメモ化されており(依存配列 `[]`)、`enabled` と `intervalMs` はプリミティブ値(boolean と number)。つまり、これらは React の `Object.is` 比較において、値が変わった時だけ正確にエフェクトを再発火させられる。無駄なタイマーのクリア&再生成が走らない。
③ クリーンアップの徹底
忘れてはいけないのが `return () => { clearInterval(timerId); }` だ。
コンポーネントがアンマウントされた後や、`enabled` や `intervalMs` が変更されてエフェクトが再実行される直前に、必ず古いタイマーを掃除する。これをサボると、メモリリークを起こしたり、すでに消滅したコンポーネントの `setState` を叩いてあの有名な赤エラー(Can’t perform a React state update on an unmounted component…)を踏むことになる。実務では絶対に避けたい地雷だ。
—
4. まとめ:明日からの君へのミッション
カスタムフックを書くときは、単にロジックを切り出すだけじゃなくて、以下の3つを自分に問いかけてみてほしい。
1. 「この引数や関数は、呼び出し元で毎回再生成されるものじゃないか?」(Ref パターンで逃がすべきか検討する)
2. 「Linterの言うことを何でもかんでも鵜呑みにして、無駄な再レンダリングを招いていないか?」(依存関係の本質を見極める)
3. 「アンマウント時や再実行時のクリーンアップは完璧か?」(メモリリークの温巣になっていないか)
状態管理とフックの依存関係のコントロールは、最初は難しく感じるかもしれない。だが、ブラウザのメモリ上で何が起きているか(参照の比較、スタックとヒープ、タイマーの挙動)を頭の中でイメージできるようになると、Reactを書くのが一気に楽しく、そして誇らしくなるはずだ。
さて、理論はこれくらいにして、さっそく自分のプロジェクトのカスタムフックを見直してみようか。何かハマったら、いつでも俺のところに相談に来いよ。

コメント