【テクニカル・上級編】 useCallbackとuseEffectの連携 – React実践ガイド

Reactにおけるエフェクトの制御、とりわけ「関数のライフサイクル」と「副作用(Side Effects)」の同期は、多くの開発者が一度は足元をすくわれる領域です。

「ESLintの警告(`exhaustive-deps`)に従って依存配列に関数を入れたら、無限ループが発生した」
「警告を無視するために `// eslint-disable-next-line` を記述したが、今度はStateの更新が反映されないゾンビバグに悩まされた」

このような経験は、Reactアプリケーションがスケールする過程で誰もが遭遇する「儀式」のようなものです。なぜこのような事態が起きるのか。そして、`useCallback` と `useEffect` をどのように連携させれば、メモリ効率と堅牢性を両立した極上のアーキテクチャを構築できるのか。

今回は、JavaScriptの言語仕様である「参照透過性」の地平から、非同期処理の競合(Race Conditions)対策、そしてカスタムフックへのカプセル化まで、現場の泥臭い実践知を交えて深く解説します。

—

1. 根本原因:JavaScriptの参照透過性とReactの差分検出

Reactがコンポーネントの再レンダリングを行う際、内部ではあらゆるオブジェクトや関数が「毎フレーム再生成」されています。まずはこの挙動を、メモリと評価式の観点から再確認しましょう。

// レンダリング毎に実行されるコンポーネント関数の中
const fetchData = () => {
console.log(userId);
};

開発者の目には、この `fetchData` は常に同じ処理を行う関数に見えます。しかし、React(ひいてはJavaScriptエンジン)にとっては、レンダリングのたびに新しいメモリ空間(アドレス)に確保される、全く別個のオブジェクトです。

Reactの `useEffect` は、依存配列(Dependency Array)に渡された要素を `Object.is()` を用いた浅い比較(Shallow Comparison)で評価します。

// レンダリング1回目
const fetchData_v1 = () => { … }; // Address: 0x001

// レンダリング2回目
const fetchData_v2 = () => { … }; // Address: 0x002

Object.is(fetchData_v1, fetchData_v2); // => false

このように、関数の参照(アドレス)が毎レンダリングで変化するため、`fetchData` を `useEffect` の依存配列にそのまま含めると、「関数が新しくなったからエフェクトを再実行する」→「エフェクト内でStateが更新される」→「再レンダリングが走り、また関数が新しくなる」 という無限ループの引き金が引かれるのです。

—

2. 解決への処方箋:`useCallback` による関数のアイデンティティ担保

この無限ループを回避し、かつエフェクト内で「常に最新のStateやPropsを参照した関数を実行する」ために導入されるのが `useCallback` です。

`useCallback` は、第2引数に渡した依存配列が変わらない限り、最初に生成した関数の参照(メモリ上の同一アドレス)をキャッシュして返し続けます。

これにより、関数のアイデンティティ(Identity)が保たれ、`useEffect` は「本当に必要な時(関数の依存値が変わった時)だけ」実行されるようになります。

堅牢な実装パターン:データの取得と競合状態の排除

実務で最も頻出する「ユーザーIDに基づいてデータを非同期で取得する」ユースケースを想定した、極めて堅牢なコードを以下に示します。ここでは、非同期処理の宿敵である「競合状態(Race Conditions)」を `AbortController` で完全に封殺するパターンを実装します。

import React, { useState, useEffect, useCallback } from ‘react’;

interface UserData {
id: string;
name: string;
email: string;
}

export const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

/

  • Pattern 1: useCallbackによる関数のメモ化
  • userIdが変更されない限り、fetchUserDataの参照(アドレス)は完全に固定される。
  • これにより、useEffectの依存配列に安全に含めることができる。

/
const fetchUserData = useCallback(async (id: string, signal: AbortSignal) => {
setLoading(true);
setError(null);
try {
const response = await fetch(`https://api.example.com/users/${id}`, { signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = (await response.json()) as UserData;
setData(result);
} catch (err) {
// AbortErrorはリクエストキャンセルによる意図的な例外のため、状態更新をスキップ
if ((err as Error).name !== ‘AbortError’) {
setError(err as Error);
}
} finally {
setLoading(false);
}
}, []); // 外部依存がないため、マウント時に生成された参照を維持

/

  • Pattern 2: useEffectでのクリーンアップと競合排除
  • 関数の参照(fetchUserData)と、動的な値(userId)を依存配列に含める。

/
useEffect(() => {
// 1. レンダリング毎、またはuserId変更時に新しいAbortControllerを生成
const controller = new AbortController();
const { signal } = controller;

// 2. メモ化された関数を実行
fetchUserData(userId, signal);

// 3. クリーンアップ関数:次のエフェクト実行時、またはアンマウント時に
// 進行中のリクエストをキャンセルし、古い非同期処理の完了がStateを上書きするのを防ぐ
return () => {
controller.abort();
};
}, [userId, fetchUserData]); // 依存配列に「メモ化された関数」と「パラメータ」を明示

if (loading) return

Loading…

;
if (error) return

Error: {error.message}

;
if (!data) return

No data available

;

return (

{data.name}

{data.email}

);
};

このコードのアーキテクチャ的価値

1. 競合状態の完全な排除: 高速で `userId` が「A → B → C」と切り替わった際、AやBのAPIレスポンスが遅れてCの後に届いても、`controller.abort()` によって古いリクエストの結果は破棄(無視)されます。
2. 参照の安定性: `fetchUserData` はコンポーネントが再レンダリングされても一切再生成されず、`useEffect` の不要なトリガーになりません。

—

3. 進化形:カスタムフックへのカプセル化(ロジックの関心事分離)

実務において、コンポーネント内に `useCallback` と `useEffect` を剥き出しで記述するのは、ノイズが多く保守性を低下させます。これらをカスタムフックに閉じ込め、UI(表示)とロジック(副作用)を疎結合にするのが優れたアーキテクトの仕事です。

先ほどのロジックを `useFetchUser` というカスタムフックに昇華させましょう。

// useFetchUser.ts (カスタムフック)
import { useState, useEffect, useCallback } from ‘react’;

interface UserData {
id: string;
name: string;
}

export const useFetchUser = (userId: string) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

// APIコールのロジックをカプセル化し、参照を固定
const executeFetch = useCallback(async (id: string, signal: AbortSignal) => {
setLoading(true);
try {
const response = await fetch(`https://api.example.com/users/${id}`, { signal });
if (!response.ok) throw new Error(‘Network response was not ok’);
const json = (await response.json()) as UserData;
setData(json);
setError(null);
} catch (err) {
if ((err as Error).name !== ‘AbortError’) {
setError(err as Error);
}
} finally {
setLoading(false);
}
}, []); // 依存配列が空であるため、この関数のアイデンティティは生涯不変

useEffect(() => {
const controller = new AbortController();

executeFetch(userId, controller.signal);

return () => {
controller.abort();
};
}, [userId, executeFetch]); // カスタムフック内部でクリーンアップと同期を完結させる

// 外部には最小限のインターフェースのみを公開
return { data, loading, error };
};

このカスタムフックを利用するコンポーネント(ビュー層)は、驚くほどクリーンになります。

// UserProfileView.tsx (ビューコンポーネント)
import React from ‘react’;
import { useFetchUser } from ‘./useFetchUser’;

export const UserProfileView: React.FC<{ userId: string }> = ({ userId }) => {
// 副作用や参照の管理はすべてカスタムフックの内部に隠蔽されている
const { data, loading, error } = useFetchUser(userId);

if (loading) return

Loading…

;
if (error) return

Error occurred: {error.message}

;
if (!data) return

No data

;

return (

);
};

—

4. 銀の弾丸ではない:`useCallback` の過剰な使用と隠れたトレードオフ

ここまで `useCallback` の有用性を熱弁してきましたが、ここで一度ブレーキを踏みましょう。すべての関数を思考停止で `useCallback` で囲むのは、パフォーマンスの観点からアンチパターンとなり得ます。

メモ化のコストを見極める

`useCallback` はタダではありません。メモ化を行うということは、以下のオーバーヘッドを毎レンダリングで支払うことを意味します。

1. 配列の生成と依存関係の比較コスト: 毎レンダリングで、`useCallback` の第2引数(依存配列)に渡された値を比較する処理(`Object.is`)が走ります。
2. メモリのオーバーヘッド: 関数の参照と依存配列の内容を保持するため、ガベージコレクション(GC)の対象から外れ、メモリを消費し続けます。

単純に「子コンポーネントへコールバック関数を渡す」だけで、その子コンポーネントが `React.memo` でラップされていない場合、`useCallback` によるメモ化は完全に無駄になります。なぜなら、親が再レンダリングされれば、子が受け取る関数の参照が同じであっても、子は強制的に再レンダリングされるからです。

いつ `useCallback` を使うべきか?

明確な基準は以下の2点に集約されます。

  • 基準1: その関数が、`useEffect` や `useMemo` の依存配列(Dependency Array)に渡される場合(今回解説したパターン)。
  • 基準2: その関数を Props として受け取る子コンポーネントが、`React.memo` で最適化されており、かつ不要な再レンダリングを防ぐことによるパフォーマンス向上が有意に観測できる場合。

—

5. Reactの未来と、開発者が進むべき道

Reactチームは、開発者がこのような「手動での参照管理(`useCallback` / `useMemo` のパズル)」に脳のリソースを割く現状を理想としていません。

その解決策として現在開発が進められているのが、コンパイラレベルで自動的に適切なメモ化をコードに施す React Compiler(旧称: React Forget) です。このコンパイラが安定版として広く普及すれば、私たちは関数の参照透過性に怯えることなく、素のJavaScriptとしてコードを書くだけで、自動的に最適なパフォーマンスを得られるようになります。

しかし、その未来が完全に定着するまでは、`Object.is` による参照比較の仕組みと、`useCallback` / `useEffect` の緻密な連携パターンを理解しておくことが、フロントエンド・アーキテクトとしての最大の武器になります。

「なぜ動くのか」だけでなく、「なぜこのコードが堅牢なのか」をコードの1行1行から語れるエンジニアであり続けましょう。

コメント

タイトルとURLをコピーしました