はじめに
Reactの関数コンポーネントで副作用や再レンダリング制御を扱う際、useCallback
フックはしばしば「面倒くさい」「よくわからない」と敬遠されがちです。しかし、大規模アプリのパフォーマンスチューニングや、子コンポーネントへのコールバック関数渡しを正しく行うためには不可欠な存在です。本記事では基礎から依存配列の書き方、内部動作、注意点、具体的な活用シーンまでを解説します。
useCallbackとは?
useCallback
は「関数をメモ化(キャッシュ)する」ための React フックです。コンポーネントが再レンダーされるたびに新しい関数インスタンスを生成すると、子コンポーネントに渡したコールバックが”変化”とみなされ、不要な再レンダーにつながります。これを防ぐのが useCallback
です。
const memoizedHandler = useCallback(() => {
console.log('クリックされました');
}, [依存値1, 依存値2]);
- 第1引数:メモ化したい関数
- 第2引数:依存配列。ここに入れた値が変わると新しく関数を作り直す
基本シグネチャと戻り値
function useCallback<T extends (...args: any[]) => any>(
callback: T,
deps: DependencyList
): T;
- T:任意の関数型
- DependencyList:
any[]
型の配列 - 戻り値:渡した
callback
と同じ型の“メモ化された関数”
内部では React が Hook ごとに前回の callback
参照を保持し、deps
が完全一致する限り同じ関数を返します。
依存配列の正しい書き方
依存配列を適切に指定しないと、次のような問題が起きます。
誤り | 症状 |
---|---|
依存配列を空にする ([] ) | 最新の外部変数を参照できない (stale closure) |
依存を省略 (undefined ) | 毎回新しい関数が作られ、効果なし |
必要な依存を含めない | 古い値を使い続ける |
例:カウントを依存に含めないと古い値を参照する
function Counter() {
const [count, setCount] = useState(0);
const handler = useCallback(() => {
console.log(count); // 依存にcountを含めないと常に0が表示
}, []);
return <button onClick={handler}>ログ出力</button>;
}
上記では count
が変わっても handler
内の count
は初回のまま。依存配列には常に最新のcountを正しく指定しましょう。
useCallbackとuseMemoの違い
- useCallback:関数をメモ化する
- useMemo:関数の戻り値(任意の値)をメモ化する
const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]);
const memoizedHandler = useCallback(() => doSomething(a, b), [a, b]);
useCallback(fn, deps)
は useMemo(() => fn, deps)
のエイリアスと考えることもできますが、可読性の観点から関数キャッシュには useCallback
を使うのが一般的です。
- コンポーネント初回レンダー
useCallback
が新しい関数を生成し返却- 次回レンダー
deps
が同じ → 前回の関数を再利用deps
が変わった → 新しい関数を生成
- 子コンポーネントへ渡す際、関数参照の変化を検出してレンダー制御できる
この仕組みを利用し、React.memo
と組み合わせることで子コンポーネントの再レンダーを抑制できます。
子コンポーネントの最適化例
const Child = React.memo(({ onAction }) => {
console.log('Child render');
return <button onClick={onAction}>アクション</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const handleAction = useCallback(() => {
console.log('Action!');
}, []); // 依存なし → 再レンダー時も同じ参照
return (
<>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>増やす</button>
<Child onAction={handleAction} />
</>
);
}
Parent
が再レンダーされてもhandleAction
は同じ参照Child
は Props が変わらないとレンダーされないため高速
使わないほうがいいケース
- 軽量コンポーネント/関数
関数の生成コストよりReactのレンダーコストのほうが低い場合 - 短命な値
毎回異なる依存配列を生成すると逆効果 - 過度のメモ化
コードが複雑になり可読性や保守性が低下
判断基準:
パフォーマンス問題が「本当に関数参照の変化」に起因しているかプロファイラで確認しましょう。
複数フックとの連携パターン
useCallback + useEffect
エフェクト内で関数を参照する場合、依存配列に含めないと stale closure が起きます。
function Search({ query }) {
const [results, setResults] = useState([]);
const fetchResults = useCallback(async () => {
const res = await fetch(`/api?q=${query}`);
setResults(await res.json());
}, [query]);
useEffect(() => {
fetchResults();
}, [fetchResults]); // fetchResults を正しく依存に含める
return /* 結果表示 */;
}
useCallback + useMemo
関数による計算結果をさらにメモ化したい場合
const expensiveCompute = useCallback((x) => heavyCalc(x), []);
const memoizedResult = useMemo(() => expensiveCompute(input), [input, expensiveCompute]);
useCallback
でメモ化された関数をテストする際は、renderHook
と act
を使うと楽です。
コールバック関数のテスト
useCallback
でメモ化された関数をテストする際は、renderHook
と act
を使うと楽です。
import { renderHook, act } from '@testing-library/react-hooks';
test('fetchResults が更新時に変化する', async () => {
const { result, rerender } = renderHook(
({ query }) => useCallback(/*…*/, [query]),
{ initialProps: { query: 'a' } }
);
const first = result.current;
rerender({ query: 'b' });
expect(result.current).not.toBe(first);
});
パフォーマンス計測とベンチマーク
- Profiler API で実際のレンダー回数を計測
- React DevTools のProfilerタブでコールバック関数生成の影響を見る
useCallback
無し / 有りでどれだけ再レンダーが減るかを数値で比較
よくある誤解とQ&A
- 「depsが空なら一度だけ実行される」
→useCallback
では「関数参照が一度だけ生成される」。useEffect([], …)
とは別。 - 「関数を渡すだけで最適化される」
→ 子コンポーネントもReact.memo
などで参照比較しなければ効果なし。 - 「常に使えば良い」
→ 過度のメモ化は逆効果。必要な箇所だけに絞りましょう。
まとめ
useCallback
は関数インスタンスの再生成を防ぎ、再レンダーを制御する強力なフックです。依存配列の正確な管理、React.memo
との併用、過度なメモ化の回避などポイントを押さえれば、アプリ全体のパフォーマンスが向上します。まずはパフォーマンスプロファイリングを行い、本当に必要な箇所だけに導入してみてください。
コメント