useCallback は関数をレンダリング間でキャッシュします。依存値のどれかが変わるまで、同じ関数オブジェクトを返すのです。これが重要になるのは、何かが関数を参照で比べるときだけで、実際には memo で包んだ子か、その関数を依存値に並べたエフェクトのことです。
どちらのボタンもメモ化されていて、同じことをします。テーマを切り替えてコンソールを見てください。表示されるのは render Inline function だけです。saveInline はレンダリングのたびに新しい関数なので、memo はpropが変わったと見なします。saveCached は毎回同じ関数なので、そのボタンは飛ばされます。
何をキャッシュするか
const cachedFn = useCallback(fn, dependencies);
fnは保っておきたい関数です。Reactはそれを呼ばず、返してくれます。dependenciesには、fnが読むコンポーネント内のすべての値を並べます。Reactはレンダリングのたびに、それらを前回のものとObject.isで比べます。- 何も変わっていなければ、前回のレンダリングの関数が返ります。何かが変わっていれば、今回渡した関数が返り、Reactは次回のためにそれを保ちます。
これが意味することに注意してください。useCallback の中のアロー関数は、ただの引数なので、やはりレンダリングのたびに作られます。useCallback はその作業を省きません。その唯一の役目は、参照が変わらないように、古くて中身が同じ関数を返すことです。
useCallbackとuseMemoの違い
両者は同じ仕組みです。useMemo は関数を呼んだ結果をキャッシュし、useCallback は関数そのものをキャッシュします。
// These two lines are equivalent
const handleSave = useCallback(() => save(draft), [draft]);
const handleSave = useMemo(() => () => save(draft), [draft]);
関数には読みやすい useCallback を使い、フィルター済みのリストやoptionsオブジェクトのような計算した値にはuseMemoを使ってください。
更新関数で依存値を減らす
キャッシュした関数は読むすべてのstateの値を並べなければならず、そのstateが変わるたびに関数も変わります。メモ化した行のリストでは、これが問題になります。todos を読む remove 関数は todos が変わるたびに変わるので、削除するたびに残りのすべての行が再レンダリングされます。セッターの更新関数の形を使えば、関数はもう todos をまったく読みません。
行を削除してください。ほかの行は前回と同じ todo オブジェクトと同じ remove 関数を受け取ったので、コンソールは静かなままです。関数を useCallback((id) => setTodos(todos.filter((t) => t.id !== id)), [todos]) に変えてもう一度試すと、削除するたびに残りのすべての行がレンダリングを記録します。
エフェクトの依存値としての関数
コンポーネントで宣言した関数を呼ぶエフェクトは、その関数を依存値に並べなければなりません。関数がレンダリングのたびに新しければ、エフェクトはレンダリングのたびに実行されます。useCallback は、関数自身の入力が変わるまでそれを安定させます。
メッセージを入力しても再接続されません。ルームを切り替えると、古いルームからの切断と新しいルームへの接続が記録されます。useCallback の包みを削除すると(アロー関数は残します)、キーを押すたびに切断と再接続が起きます。
ただし、ここにはもっと簡単な直し方があります。関数をエフェクトの中に移すことです。そうすればエフェクトは roomId に直接依存し、安定させるべき関数はなくなります。
useEffect(() => {
function createOptions() {
return { serverUrl: 'wss://chat.example', roomId };
}
const options = createOptions();
// ...
}, [roomId]);
関数がエフェクトでしか使われないなら、こちらを選んでください。useCallback は、下へ渡したりカスタムフックから返したりして、呼び出す側がそれを自分の依存配列に入れるかもしれない関数のために取っておきましょう。
関数が変わったかどうかを確かめる
memoの子がレンダリングされ続けて理由がわからないときは、関数のpropがレンダリング間で本当に同じオブジェクトかどうかを確かめてください。前回のものをrefに保っておいて比べます。
Countをクリックすると、step は変わっていないので、コンソールには increment changed: false と表示されます。Stepをクリックすると true と表示されます。step が依存値なので、Reactは新しい関数を返したのです。レンダリングのたびに変わる関数は、たいてい、コンポーネント内で宣言したオブジェクトや別の関数のように、依存値そのものがレンダリングのたびに新しいことを意味します。
カスタムフックの中のuseCallback
関数を返すカスタムフックは、誰がそれを呼ぶかわからなくても、useCallback を使うのに適した場所です。フックを使うコンポーネントは、その関数をmemoの子に渡したり、エフェクトに並べたりするかもしれず、外側からそれを安定させることはできません。フックの中で包んでおけば、どの呼び出し側も最初から安定した参照を得られます。
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount((c) => c + 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, reset };
}
よくある間違い:すべてを包む
<button> や <input> に直接渡すハンドラを useCallback で包んでも、何の役にも立ちません。DOM要素のハンドラが同じだからといってReactが作業を省くことはなく、memo で包まれていないコンポーネントは、親がレンダリングされればどのみちレンダリングされます。
// Pointless: <button> does not care that the function is the same
const handleClick = useCallback(() => setOpen(true), []);
return <button onClick={handleClick}>Open</button>;
useCallback を1つ増やすたびに、誰かが間違えうる依存配列が1つ増え、古くなった依存値は本物のバグになります(関数が古いstateを読み続けます)。既定で付けるのではなく、受け取る側にmemoの子や依存配列があるときに追加してください。
ビルドでReact Compiler 1.0を使っていれば、関数と値は自動でメモ化されるので、手書きの useCallback 呼び出しはほとんど不要になります。コンパイラがなければ上のルールが引き続き当てはまり、手書きの useCallback は既存のコードで今もよく使われています。
よくある質問
useCallbackは何をするものですか?
依存値のどれかが変わるまで、すべてのレンダリングで同じ関数オブジェクトを返します。関数が作られること自体は止めません。新しい関数の代わりに、前のレンダリングの関数を返すのです。
useCallbackはいつ使うべきですか?
memo で包んだ子に関数を渡すとき、またはその関数がエフェクトやほかのフックの依存値になっているときです。どちらの場合も、レンダリングのたびに新しい関数になると、最適化が打ち消されたりエフェクトが再実行されたりします。
useCallbackとuseMemoの違いは何ですか?
useCallback(fn, deps) は fn そのものをキャッシュします。useMemo(() => value, deps) は関数が返す値をキャッシュします。useCallback(fn, deps) はちょうど useMemo(() => fn, deps) と同じです。
すべての関数をuseCallbackで包むべきですか?
いいえ。関数を普通のDOM要素やメモ化されていないコンポーネントに渡すなら、参照が安定していても何も変わらず、依存配列が増えるとコードが読みにくく、間違えやすくなります。
useCallbackを使うとコンポーネントは速くなりますか?
それだけでは速くなりません。役立つのは、memo やエフェクトの依存配列のように、下流で何かが関数を参照で比べるときだけです。