カスタムフック設計の極み:依存配列の迷宮と引数インターフェースの数学的解法
やあ。日夜、V8エンジンの機嫌を取りながら、Reactのレンダリングパイプラインと格闘している君なら、一度はこんな絶望を味わったことがあるはずだ。
「なぜ、このカスタムフックは無限ループを引き起こすのか?」
「なぜ、非同期処理の完了後に古びた(Stale)クロージャの亡霊が状態を書き換えてしまうのか?」
カスタムフックはReactの抽象化において最強の武器だが、その内部構造と依存関係の管理を少しでも誤れば、それはたちまちアプリケーション全体を蝕む時限爆弾へと変貌する。
今日は、公式ドキュメントの表面をなぞっただけでは決して辿り着けない、カスタムフックの引数設計と依存関係管理の深淵について、アーキテクトの視点から解き明かしていこう。
—
1. カスタムフックの引数設計:プリミティブか、オブジェクトか、それとも「関数」か
カスタムフックを作る際、最初に直面するのが引数のインターフェース設計だ。例えば、特定のAPIポーリングを行うフックを考えてみよう。
// 悪い例:直感的だが、依存関係の管理において地雷原となる設計
function useApiPoll(url: string, options: { retries: number, headers: Record
// …
}
このインターフェースは一見して綺麗に見える。しかし、これをそのままコンポーネント内で呼び出すとどうなるか。
function UserDashboard({ userId }: { userId: string }) {
// 毎回レンダリングのたびに新しいオブジェクトリテラルが生成される
useApiPoll(`/api/users/${userId}`, {
retries: 3,
headers: { Authorization: ‘Bearer token’ }
});
return
;
}
JavaScriptのオブジェクトは参照比較(Referential Equality)の世界に生きている。親コンポーネントが再描画されるたびに、`options` オブジェクトは新しいメモリ領域に生成され、別物として扱われる。もしカスタムフック内部で `useEffect` の依存配列にこの `options` を置いていたらどうなるか?――そう、無限レンダリングの完成だ。
解決策:引数の「アトミック化」と「遅延評価(Thunk)」
真に堅牢なカスタムフックは、引数として渡される値の「参照の不安定さ」に対して耐性を持たなければならない。設計アプローチとしては以下の2つが考えられる。
1. プリミティブへの分解: オブジェクト全体ではなく、必要な値(例: `retries`)を直接引数に取るか、内部で `useMemo` / `useRef` で安定化させる。
2. ビヘイビア(関数)の受け入れ: 動的な値やコールバックは、安易にオブジェクトで受け取るのではなく、関数として受け取るか、`useCallback` の使用を前提とするドキュメント化を行う。
—
2. 依存関係の迷宮:`useCallback` と `useRef` によるクロージャの調伏
カスタムフック内で非同期処理やイベントリスナーを扱う際、最も厄介なのが「Stale Closure(古いクロージャ)」の問題だ。
次のコードを見てほしい。一見、完璧に動くように見えるカスタムフックだ。
import { useState, useEffect, useCallback } from ‘react’;
// デバウンス付きの非同期保存を行うカスタムフック
function useDebouncedSave
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const timer = setTimeout(async () => {
setIsSaving(true);
try {
await saveFn(data); // ここで参照されている `data` や `saveFn` は本当に最新か?
} finally {
setIsSaving(false);
}
}, delay);
return () => clearTimeout(timer);
}, [data, saveFn, delay]); // saveFn が親で useCallback されていない場合、毎回タイマーがリセットされる
return { isSaving };
}
このフックの致命的な欠陥は、親コンポーネント側で `saveFn` がメモ化(`useCallback`)されていない場合、タイマーが発火する前に親が再描画されるたびに `useEffect` のクリーンアップが走り、デバウンスが永遠に完了しない(APIが呼ばれない)というバグを生む点だ。かといって `saveFn` を依存配列から外すと、今度は古い `saveFn` が古い `data` を掴み続ける。
究極のパターン:Refパターンによる依存関係のバイパス
このジレンマを鮮やかに解決するのが、「最新の値を `useRef` に退避させる」というアーキテクチャパターンだ。
import { useState, useEffect, useRef, useCallback } from ‘react’;
// 改善版:refを活用して依存配列の汚染を防ぐ
function useDebouncedSaveRef
const [isSaving, setIsSaving] = useState(false);
// 1. 最新の関数やデータを常にRefに保持する(レンダリングをトリガーしない)
const saveFnRef = useRef(saveFn);
const dataRef = useRef(data);
// レンダリングごとにRefを最新にアップデート
useEffect(() => {
saveFnRef.current = saveFn;
dataRef.current = data;
});
useEffect(() => {
const timer = setTimeout(async () => {
setIsSaving(true);
try {
// 2. 実行時にはRef経由で最新の値にアクセスする
await saveFnRef.current(dataRef.current);
} finally {
setIsSaving(false);
}
}, delay);
return () => clearTimeout(timer);
}, [delay]); // 依存配列にはプリミティブな delay のみを指定。無限ループとは完全に決別する。
return { isSaving };
}
このアプローチにより、フックの消費者は `saveFn` を `useCallback` でラップする強迫観念から解放される。カスタムフック側が「外側の世界の揺らぎ」をスマートに吸収しているからだ。これぞ、優れたアーキテクチャの姿と言える。
—
3. メモリリークと非同期競合(Race Condition)の防止
カスタムフック内で非同期処理を扱う場合、コンポーネントがアンマウントされた後の状態更新(`Warning: Can’t perform a React state update on an unmounted component…`)や、リクエストの順序が逆転する競合状態への配慮がプロフェッショナルの条件だ。
特に、引数(例えば検索クエリやID)が動的に変わる非同期フックでは、「AbortController」と「isMountedフラグ(あるいはクリーンアップ関数でのフラグ管理)」を組み合わせるべきだ。
import { useState, useEffect } from ‘react’;
function useAsyncResource
const [data, setData] = useState
const [loading, setLoading] = useState
const [error, setError] = useState
useEffect(() => {
// ネイティブの AbortController を用いて、非同期処理を外部からキャンセル可能にする
const controller = new AbortController();
setLoading(true);
setError(null);
fetcher(controller.signal)
.then((result) => {
// アンマウント済み、あるいは古いリクエストであれば状態を更新しない
if (!controller.signal.aborted) {
setData(result);
setLoading(false);
}
})
.catch((err) => {
if (!controller.signal.aborted) {
if (err.name !== ‘AbortError’) {
setError(err);
}
setLoading(false);
}
});
return () => {
// 依存関係が変わる、またはアンマウントされた瞬間に直前のリクエストを即座にabort
controller.abort();
};
}, deps); // 呼び出し元から渡された依存配列に完全に準拠
return { data, loading, error };
}
このカスタムフックは、メモリ効率とネットワーク帯域の無駄遣いを同時に防ぐ。ブラウザのイベントループやメモリの寿命を意識した、非常に実践的なコードだ。
—
まとめ:優れたカスタムフックは「黒魔術」を隠蔽する
私たちが書くカスタムフックは、UIコンポーネントから複雑性(複雑な非同期処理、副作用、メモ化の苦悩)を剥ぎ取り、宣言的なコードへと昇華させるためのものであるべきだ。
1. 引数は必要最小限に、あるいは参照の不安定さを `useRef` でラップして吸収する。
2. 依存配列の地雷(コールバックの再生成など)を Ref パターンで巧みに回避する。
3. 非同期処理のライフサイクル(アンマウント、競合、中断)をフック内部で完全にカプセル化する。
これらを満たしたカスタムフックを設計できた時、あなたの書くReactアプリケーションは、どれほど大規模になろうとも、驚くほど軽快で、予測可能で、堅牢な挙動を維持し続けるだろう。
さあ、エディタに戻って、その場しのぎで書いたコードの依存配列を今すぐ見直そうじゃないか。

コメント