おい、みんな。今日も一日お疲れさん。画面の向こうで日々Reactと格闘している君たちに、今日はちょっと込み入った話、でも知っておくと確実に「デキる」エンジニアになれる、そんなとっておきの話をしよう。テーマは`useEffect`と`useCallback`、この二つのフックの「現場で本当に役立つ連携術」だ。特に、関数を`useEffect`の依存配列に含める際の、あのモヤモヤ、あの無限ループの悪夢から解放されるための極意を伝授する。
useEffectとuseCallback、現場で本当に役立つ連携術:関数の安定性を保つ極意
Reactのコンポーネントは、状態やプロップスが変わるたびに再レンダーされる。これはReactの基本中の基本だが、この「再レンダー」が、時として我々の意図しない挙動やパフォーマンスの低下を引き起こすことがある。特に`useEffect`フックは、コンポーネントのライフサイクルと密接に結びついており、その依存配列の扱いはまさに腕の見せ所だ。
1. `useEffect`の依存配列に潜む「関数の参照同一性」という落とし穴
`useEffect`は、コンポーネントがレンダーされた後に特定の副作用(データフェッチ、DOM操作、イベントリスナーの設定など)を実行するための強力なツールだ。その動作を制御するのが「依存配列」。この配列に含められた値が前回のレンダー時と変わっていれば、`useEffect`のコールバック関数が再実行される。
ここで問題になるのが、「関数」を依存配列に含めるケースだ。
import React, { useState, useEffect } from ‘react’;
function BadComponent() {
const [count, setCount] = useState(0);
const [data, setData] = useState([]);
// 問題のある関数定義
const fetchData = () => {
// 実際にはAPIを叩くなどの処理
console.log(‘Fetching data…’);
return Promise.resolve([‘item1’, ‘item2’, `count: ${count}`]);
};
useEffect(() => {
console.log(‘useEffect callback executed!’);
fetchData().then(result => {
setData(result);
});
// クリーンアップ関数(ここでは省略)
}, [fetchData]); // 問題:fetchDataが毎回新しい関数として認識される
return (
Bad Component Example
Count: {count}
Data:
-
{data.map((item, index) => (
- {item}
))}
);
}
この`BadComponent`の例を見てくれ。`fetchData`という関数を`useEffect`の依存配列に入れている。さて、君はこのコードがどのように動くと思う?
君が「`count`が変わらない限り、`fetchData`は同じ関数として扱われて`useEffect`は再実行されないんじゃないか?」と思ったなら、それは半分正解で半分間違いだ。
ブラウザの裏側で何が起こっているか:
JavaScriptにおいて、関数はオブジェクトの一種だ。そして、オブジェクトはたとえ全く同じコード内容であっても、それが毎回新しく定義されれば、異なるメモリ参照を持つ「新しいオブジェクト」として扱われる。
Reactのコンポーネント関数は、プロップスやステートが変更されるたびに、その全体が再実行される。つまり、`BadComponent`が再レンダーされるたびに、`fetchData`という関数は毎回新しいインスタンスとして定義され直しているんだ。
結果として、`useEffect`の依存配列に含まれる`fetchData`は、レンダーごとに「新しい関数オブジェクト」として認識され、たとえ`count`が変わっていなくても`useEffect`のコールバック関数が毎回再実行されてしまう。これは無駄な処理であり、データフェッチが走ったり、イベントリスナーが登録・解除を繰り返したりすれば、パフォーマンスに悪影響を及ぼし、最悪の場合は無限ループに陥る可能性もある。まさに現場の地雷だ。
2. `useCallback`の登場:関数の参照同一性を安定させる救世主
そこで登場するのが、`useCallback`フックだ。こいつの役割はたった一つ、関数の「メモ化」だ。
「メモ化」というのは、簡単に言えば「同じ入力(依存配列)に対しては、同じ出力(関数の参照)を返すようにキャッシュする」ということ。
`useCallback`は、引数として渡された関数と、その関数の依存配列を受け取る。そして、Reactは前回のレンダー時と今回のレンダー時で、`useCallback`に渡された依存配列の中身を比較する。
- 依存配列に変化がない場合: `useCallback`は前回のレンダー時に生成された関数のインスタンスを再利用する。
- 依存配列に変化があった場合: `useCallback`は新しい関数のインスタンスを生成して返す。
これにより、`fetchData`のような関数を`useEffect`の依存配列に入れる際に、不必要に`useEffect`が再実行されるのを防ぐことができるわけだ。
3. `useCallback`と`useEffect`の連携:実践的な設計パターン
それでは、先ほどの問題のあるコードを`useCallback`を使って改善してみよう。
import React, { useState, useEffect, useCallback } from ‘react’;
function GoodComponent() {
const [count, setCount] = useState(0);
const [data, setData] = useState([]);
const [userId, setUserId] = useState(1); // 新しく依存するステートを追加
// ✅ useCallbackで関数をメモ化する
// 依存配列にuserIdを含めることで、userIdが変わった時のみ新しい関数を生成する
const fetchData = useCallback(async () => {
console.log(`Fetching data for userId: ${userId}, current count: ${count}…`);
// 実際にはAPIを叩くなどの処理
// ここでは非同期処理を模倣するためにPromiseを使用
const response = await new Promise(resolve => setTimeout(() => {
resolve([`User ${userId} Item A`, `User ${userId} Item B`, `Count: ${count}`]);
}, 500));
return response;
}, [userId]); // 依存配列にuserIdを含める。countはここでは含めない(後述)
useEffect(() => {
// useEffectの依存配列にメモ化されたfetchData関数を含める
// fetchDataの依存配列(userId)が変わらない限り、このuseEffectは再実行されない
console.log(‘useEffect callback executed for data fetching!’);
fetchData().then(result => {
setData(result);
});
// クリーンアップ関数(ここでは副作用がないため省略)
return () => {
console.log(‘useEffect cleanup (if any) executed.’);
};
}, [fetchData]); // ✅ メモ化されたfetchDataを依存配列に含める
return (
Good Component Example
Count: {count}
User ID: {userId}
Data:
-
{data.map((item, index) => (
- {item}
))}
);
}
// このコンポーネントをエクスポートしてApp.jsなどで使用
export default GoodComponent;
// — 使用例 —
// App.js
/
import React from ‘react’;
import GoodComponent from ‘./GoodComponent’; // 上記のファイルパスに合わせて調整
function App() {
return (
);
}
export default App;
/
この`GoodComponent`では、`fetchData`関数を`useCallback`でラップしている。
- `fetchData`の依存配列には`userId`を含めている。これは、`fetchData`が`userId`の値に依存しているためだ。
- `useEffect`の依存配列には、このメモ化された`fetchData`をそのまま入れている。
これで何が変わったか?
1. `count`が変更された場合:
- `GoodComponent`が再レンダーされる。
- `useCallback`は`fetchData`の依存配列(`[userId]`)をチェックする。`userId`は変わっていないため、`fetchData`は前回のレンダー時と同じ関数の参照を返す。
- `useEffect`は依存配列(`[fetchData]`)をチェックする。`fetchData`の参照が変わっていないため、`useEffect`のコールバックは再実行されない。
- ただし、`fetchData`内部で`count`を参照しているため、古い`count`の値が使われる可能性がある。 これが次の重要なポイントだ。
2. `userId`が変更された場合:
- `GoodComponent`が再レンダーされる。
- `useCallback`は`fetchData`の依存配列(`[userId]`)をチェックする。`userId`が変わっているため、`fetchData`は新しい関数のインスタンスを生成して返す。
- `useEffect`は依存配列(`[fetchData]`)をチェックする。`fetchData`の参照が変わっているため、`useEffect`のコールバックが再実行される。
- これにより、`userId`の変更に対応してデータフェッチが走り、UIが更新される。
4. `useCallback`と依存配列:内部の変数参照に注意!
上記の例で「`count`はここでは含めない」と書いたが、これは意図的にそうしている。
もし`fetchData`が`count`の値に論理的に依存して、`count`が変わるたびに`useEffect`を再実行したいのであれば、`useCallback`の依存配列に`count`を含める必要がある。
// もしcountの変更でfetchDataを再生成し、useEffectも再実行したい場合
const fetchData = useCallback(async () => {
console.log(`Fetching data for userId: ${userId}, current count: ${count}…`);
// …データフェッチロジック…
}, [userId, count]); // ✅ countも依存配列に含める
しかし、多くの場合、`fetchData`は`userId`のような「外部からデータを取得するためのキー」には依存するが、`count`のようなUIの状態には直接依存しないことが多い。この場合、`count`を`useCallback`の依存配列に入れてしまうと、`count`が変わるたびに`fetchData`が再生成され、結果的に`useEffect`も再実行されてしまう。これは、`count`の変更ではデータフェッチをトリガーしたくない場合に、`useCallback`を使う意味が薄れてしまう。
なぜ`fetchData`内部の`count`参照はそのままにできるのか?
`fetchData`内部で`count`を参照している場合、`useCallback`の依存配列に`count`を含めないと、`fetchData`が生成された時点の`count`の値(クロージャ内の値)が使われ続けてしまうように見える。これは正しい懸念だ。
この場合、いくつかのパターンがある:
1. `count`の最新値が必要な場合:
- `useCallback`の依存配列に`count`を含めるのが最も素直な方法。
- あるいは、`useRef`を使って可変参照を保持し、`current`プロパティ経由で最新値にアクセスする方法もある。ただし、これは依存配列の変更をトリガーしないため、`useEffect`の再実行には影響しない。
- イベントハンドラなどで`count`を使う場合は、`useCallback`の依存配列に含めても問題ないことが多い。
2. `fetchData`の実行自体は`count`に依存しないが、内部のロギングなどで使いたい場合:
- このケースが先ほどの`GoodComponent`の例だ。`fetchData`は`userId`に基づいてデータを取得するが、ログに`count`を表示したいだけ。この場合、`count`の更新で`fetchData`が再生成されて`useEffect`が再実行されるのは無駄なので、`count`は依存配列に入れない。
- ただし、この場合の`console.log`は、`fetchData`が最初に生成された時点の`count`の値を表示することになるので注意が必要だ。
このように、`useCallback`の依存配列には、その関数が「論理的に依存している」状態やプロップスのみを含めるのがベストプラクティスだ。不必要な依存は、`useCallback`のメリットを相殺する。
5. `useCallback`の過度な使用は逆効果?
`useCallback`は強力だが、万能薬ではない。無闇に全ての関数を`useCallback`でラップするのは避けるべきだ。
- メモ化のコスト: `useCallback`自体も、依存配列の比較や内部的なキャッシュの管理といったオーバーヘッドがある。
- 可読性の低下: あらゆる関数に`useCallback`が付いていると、コードが冗長になり、何が本当にメモ化を必要としているのかが分かりづらくなる。
では、どんな時に`useCallback`を使うべきか?
- `useEffect`の依存配列に関数を含める場合: これが今日のメインテーマだ。特に、クリーンアップ処理が伴うイベントリスナーの登録・解除など、副作用の制御が重要な場合に必須級。
- 子コンポーネントにコールバック関数を渡す場合: 子コンポーネントが`React.memo`などでメモ化されている場合、親から渡されるコールバック関数が毎回新しい参照だと、子コンポーネントが不必要に再レンダーされてしまう。この場合に`useCallback`でコールバック関数を安定させることで、子の再レンダーを抑制できる。
- カスタムフックから関数を返す場合: カスタムフックが返す関数が、そのフックを使用するコンポーネントの`useEffect`や`useMemo`の依存配列に含まれる可能性がある場合、`useCallback`で安定させておくのが親切だ。
6. まとめ:賢く使いこなすための道標
今日の話は、`useEffect`と`useCallback`の連携、特に「関数の参照同一性」というReactの深い部分に触れる内容だった。
- `useEffect`の依存配列に関数を入れる際は、その関数が再レンダーごとに新しいインスタンスとして生成されることを意識しろ。
- `useCallback`は、この関数の再生成を抑え、依存配列が変わらない限り同じ関数の参照を返すことで、`useEffect`の不必要な再実行を防ぐ。
- `useCallback`自身の依存配列には、その関数が論理的に依存している値だけを含めること。不必要な依存はメリットを打ち消す。
- 過度なメモ化は避け、本当に必要な場所で戦略的に活用する。
Reactは非常に賢いフレームワークだが、その挙動を深く理解し、適切なフックを適切な場面で使うことで、我々はより堅牢で高性能なアプリケーションを構築できる。今日の知見が、君たちの現場での開発を少しでもスムーズにし、また一つ「なるほど!」という発見につながれば幸いだ。
さあ、今日の学びを胸に、明日からも最高のコードを書き続けようぜ!

コメント