こんにちは。フロントエンドの現場で日々、DOMの差分アルゴリズムやメモリ上の参照の揺らぎと格闘しているチーフアーキテクトの私だ。
さて、今回はReactのパフォーマンスチューニングにおいて、誰もが一度は通り、そして最も誤解されやすい魔窟である `useCallback` による関数Propsの安定化について深掘りしよう。
ネット上の浅いチュートリアルを見ていると、「再レンダリングを防ぐためにすべての関数を `useCallback` で囲め」という暴論がまかり通っている。だが、ブラウザのエンジンやReactの内部挙動を愛する者からすれば、それは失笑を買うレベルのアンチパターンだ。無駄なメモ化こそが、かえってメモリを圧迫し、GC(ガベージコレクション)の負荷を高め、最悪のパフォーマンスを引き起こすという現実を、私たちは知っている。
今回は、真に堅牢でスケールするWebアプリケーションを構築するために、`useCallback` をいつ、どこで、どのように使うべきか、そのアーキテクチャの極限を語り尽くそう。
—
なぜ関数Propsはレンダリングごとに「別物」になるのか?
JavaScriptの基本に立ち返ろう。関数もオブジェクトである。
Reactコンポーネントが再レンダリングされるということは、すなわちその関数コンポーネント(ただのJavaScriptの関数)がもう一度頭から実行されることを意味する。
// レンダリングされるたびに、メモリ上のまったく新しいアドレスに新しい関数インスタンスが生成される
const handleClick = () => {
console.log(“Clicked”);
};
親が再レンダリングされれば、その内部で定義された関数も毎回新しく生成される。この「新しく生成された関数」を、`React.memo` で最適化された子コンポーネントにPropsとして渡したとき、悲劇が起きる。
`React.memo` は、渡されたPropsの「浅い比較(Shallow Equal)」を行う。
`oldProps.onClick === newProps.onClick` ——当然、メモリ上の参照先(ポインタ)が変わっているため、この比較は `false` になる。結果として、「Propsが変わった」と判定され、子コンポーネントは無駄な再レンダリングを強制されるのだ。
ここに `useCallback` の出番がある。依存配列(Dependency Array)が変化しない限り、キャッシュされた同一の関数参照を返し続けることで、この「参照の揺らぎ」をねじ伏せるわけだ。
—
現場で即座に使える:堅牢なカスタムフックと `useCallback` の実例
では、実際のプロダクションコードを想定した実装を見てみよう。
ここでは、大量のリストアイテムを持つ仮想的なダッシュボードを想定する。子コンポーネントへの不要な再レンダリングを防ぎつつ、非同期処理の競合や、最新のステートへのアクセス(Stale Closure問題)を完全に制御したコードだ。
import React, { useState, useCallback, memo } from ‘react’;
// — 子コンポーネント —
// React.memoでラップし、Propsが変化しない限り絶対に再レンダリングさせない
interface ListItemProps {
id: string;
name: string;
onSelect: (id: string) => void;
}
const ListItem: React.FC
console.log(`[Render] ListItem: ${name} が描画されました`);
return (
);
});
ListItem.displayName = ‘ListItem’;
// — 親コンポーネント —
interface Item {
id: string;
name: string;
}
export const Dashboard: React.FC = () => {
const [items] = useState
{ id: ‘1’, name: ‘アーキテクチャ設計書’ },
{ id: ‘2’, name: ‘API仕様書’ },
{ id: ‘3’, name: ‘セキュリティ監査レポート’ },
]);
const [selectedId, setSelectedId] = useState
const [filterKeyword, setFilterKeyword] = useState
// 【極意】useCallbackによる関数の安定化
// 依存配列に余計なステート(filterKeywordなど)を含めないことが肝要。
// 関数内で最新のステートを参照したい場合は、関数型アップデートを使うか、
// 後述するuseRefのテクニックを活用する。
const handleSelect = useCallback((id: string) => {
console.log(`アイテム選択ID: ${id}`);
setSelectedId(id);
// もしここで「最新の filterKeyword」が必要な場合、
// 依存配列に filterKeyword を入れると、フィルターが変わるたびにこの関数が再生成され、
// ListItem の memo化が無意味になる。
// その対策については次のセクションで解説する。
}, []); // 依存配列が空なので、この関数はマウント時から一生不変
return (
ドキュメント管理ダッシュボード
setFilterKeyword(e.target.value)}
/>
現在選択中のID: {selectedId ?? ‘なし’}
))}
);
};
このコードにおいて、ユーザーが検索フォーム(`filterKeyword`)に文字を入力しても、親コンポーネントである `Dashboard` は再レンダリングされるが、`handleSelect` の参照は `useCallback` によって完全に担保されている。そのため、`ListItem` は一切再レンダリングされない。これが、レンダリング負荷を極限まで削ぎ落とす王道のアーキテクチャだ。
—
誰もがハマる「Stale Closure(古いクロージャ)」の罠と回避策
`useCallback` を使う上で、避けて通れない最大の難所が Stale Closure(古いクロージャの参照) だ。
先ほどのコードで、もし `handleSelect` の中で「最新の `selectedId`」を使ったバリデーションを行いたくなったとする。素朴なエンジニアは、こう書くだろう。
// ❌ 悪い例:依存配列に selectedId を入れてしまう
const handleSelect = useCallback((id: string) => {
if (selectedId === id) {
console.log(“すでに選択されています”);
return;
}
setSelectedId(id);
}, [selectedId]); // selectedId が変わるたびに関数が再生成され、ListItemの memo が破壊される!
これでは本末転送だ。`selectedId` が変わるたびに関数が再生成され、子コンポーネントの再レンダリングを防ぐという目的が完全に失われる。
では、どうするか?
ここでプロのフロントエンド・アーキテクチャが誇る秘技、`useRef` による最新値の保持(Ref Synchronization Pattern) を使おう。
import { useRef, useEffect, useCallback } from ‘react’;
// 最新の値を常に保持するカスタムフック(または直接実装)
function useLatest
const ref = useRef(value);
// レンダリングごとに最新の値をrefに書き込む(副作用のタイミングに注意)
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
export const AdvancedDashboard: React.FC = () => {
const [selectedId, setSelectedId] = useState
// 最新の selectedId を参照し続けるref
const selectedIdRef = useLatest(selectedId);
// 依存配列を完全に空にしながら、中の処理では常に「最新のステート」にアクセスできる
const handleSelect = useCallback((id: string) => {
// ref.current を参照するので、クロージャが古くならない!
if (selectedIdRef.current === id) {
console.log(“すでに選択されています”);
return;
}
setSelectedId(id);
}, []); // 依存配列は空のままでOK!関数は完全に安定化される。
};
このアプローチを取ることで、「関数の参照は絶対に不変(`useCallback` による最適化の維持)」 と 「非同期処理やイベントハンドラ内での最新ステートの参照」 の二兎を完全に追うことができる。これが実務レベルの堅牢性だ。
—
いつ `useCallback` を使ってはいけないのか?(アンチパターンの見極め)
最後に、アーキテクトとして警鐘を鳴らしておきたい。
「すべての関数を `useCallback` で囲むな」。
以下の条件に当てはまる場合、`useCallback` を使うべきではない。むしろ、百害あって一利なしだ。
1. 渡す先の子コンポーネントが `React.memo` で最適化されていない場合
子側が普通のコンポーネントであれば、どうせ親が再レンダリングされた時点で子も再レンダリングされる。そこに `useCallback` を挟んでも、ただCPUに「依存配列の比較処理」という無駄なオーバーヘッドを背負わせるだけである。
2. コンポーネント内部の非常にシンプルなハンドラである場合
`useCallback` 自体の実行コスト(配列の作成、メモ化機構の維持)は、極めて軽微ではあるがゼロではない。何でもかんでもメモ化すると、JavaScriptエンジン(V8など)のメモリ使用量を無駄に圧迫し、GCの頻度を上げてしまい、結果としてカクつきの原因になる。
最適化の黄金律
> 「計測せよ、推測するな。」
> React DevToolsのProfilerを使い、本当にそのコンポーネントがボトルネックになっているのかを確認してから `useCallback` と `React.memo` を投入せよ。
—
結びに代えて
`useCallback` は、単なる「おまじない」ではない。Reactのレンダリングパイプライン、JavaScriptのクロージャの挙動、そしてメモリ管理の仕組みを深く理解したエンジニアが手にする、極めて鋭いメスだ。
表面的なテクニックに惑わされず、コードの裏側で何が起きているのかを常に想像し、優美で堅牢なアーキテクチャを組み上げてほしい。君たちの健闘を祈る。

コメント