React hooks にはメモ化のための useCallback と useMemo という関数があります。
hooks を使い始めて、この二つの関数を知った私はこう思いました。
「え?無条件でパフォーマンス上がるんなら全部これで書くべきやん!」
と。
というわけで、しばらくそのスタンスで書いてきたのですが、果たしてこの「無条件でパフォーマンスが上がる」という前提は本当に正しいのか、というかそもそも"パフォーマンス上がる"とは具体的に何をしてくれるのかを理解せずに使っていたので、ここで「全て useCallback/useMemo で書く」という方針が正しいのか、それとも他の方針が存在するかを考えてみました。
大きく次の3つの観点で考えます。
- パフォーマンス
- 可読性
- バグの発生しやすさ
1.パフォーマンス
「そもそも useCallback/useMemo はパフォーマンス向上の用途なのに、パフォーマンス観点で気にすることあるんか?」
と思った方もいらっしゃるかもしれません。まあ私もそう思っていたんですが、昔も「PureComponentにするとむしろパフォーマンスが落ちるケースがある」みたいなこともあったので、無思考にそう信じるのもよろしくないと思うのでこれをしっかり理解してみようと思います。
useCallback/useMemo 自体の処理コストを考える
先ほどの前提が正しいかを確認するためには「useCallback、useMemoを使った時のコスト」と「使わなかった時のコスト」を比べる必要があります。
まず useCallback/useMemo の処理のコストというのは「メモ化した値(厳密言うと deps ですが)との比較」です。
hooks は基本的に dependencyList(この記事ではこれ以降 deps と呼びます) と呼ばれるものを配列とした引数に取り、その値が変わった時に値が更新されるようになっています。
例として一度 mount した後の useCallback の処理である updateCallback を見てみましょう
react/ReactFiberHooks.js at master · facebook/react · GitHub
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
}
hook.memoizedState = [callback, nextDeps];
return callback;
}
この中の areHookInputsEqual というものが比較のための関数みたいですね。これも見てみましょう。
function areHookInputsEqual(
nextDeps: Array<mixed>,
prevDeps: Array<mixed> | null,
) {
// いろいろ warning 割愛
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
}
指定された deps 毎に is
という関数で比較しているようです。さらにそれも見てみましょう。
react/objectIs.js at master · facebook/react · GitHub
function is(x: any, y: any) {
return (
(x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
);
}
(全然話逸れるが、なぜこれが Object.is と同値なのか全く分からない。教えて強い人。)
なのでこの is関数 x deps に指定された要素の数が useCallback/useMemo 自体のコストと考えて良さそうです。次に使わない場合のコスト、言い換えると useCallback/useMemo がどんな状態の時にどんな最適化をもたらしているかを見ていきましょう。
useMemo の恩恵
これまで useCallback/useMemo とひっくるめて語っていましたが、ここからは分けて考えます。
useMemo は計算した結果を保持するための関数です。
const memoedValue = React.useMemo(() => /* 何かしらの複雑な計算 */, [])
なので useMemo を使わない場合の処理コストは、中身で行なっている計算によります。 その計算が is関数 x deps の数より重ければ useMemo を使った方がお得ということになります。ただいちいち「これは useMemo 使った方がパフォーマンスいいのか?」と考えるのもそれはそれで生産性低いので、ある程度自動的に使う使わないの判断ができるような軸が欲しい…と思いましたが、いい感じの軸が思いつきませんでした。
例えば次のようなものは useMemo 使うまでもないとパッと見で分かると思うんですが、
const checkedAll = checkedIds.length === items.length
もう少し複雑なものがどっちがお得かは実際に検証してみないと分からないでしょう。一々そんなことをするのも馬鹿らしいので、疑わしきものはとりあえず useMemo みたいな方針が濃厚なのかなという気がします。
useCallback の恩恵
まず useCallback による最適化は useMemo の純粋にメモ化することによって計算量を抑えるのとは意味合いが違います。
実際に検証してみたわけではないですが(めんどくさい)、関数インスタンスを作成するコストは先ほどの is関数の比較するコストよりおそらく低いでしょう。このコストだけ考えると「あれ?useCallbackってコスト増えるだけで意味なくね?」と思うかもしれませんが、そうではありません。
useCallback は不要に新しく関数インスタンスを作成することを抑制することによって不要な再描画を減らしてくれます。詳しい話はこちらの記事のアンチパターンとして挙がっている「アロー関数をpropsに即時関数で渡す」を読むと分かりやすいと思います。こちらの記事では bind を使うことによって新しく関数が作成されることを防いでいますが、 useCallback を使うことにより Class でなくとも同じことができるようになります。
逆に言うと、子供のコンポーネントに対して関数を渡すようなことがなければ、特に useCallback を使う意味はないということです。(厳密に言うと PureComponent のように Props の変化に対して shallow equal で再描画判定を行うような場合限定なのですが、親が子のコンポーネントがどうやって再描画判定しているのかを気にするのも不自然な話なので、子に関数を渡している場合は useCallback で包んでおけばいいと思います。)
まとめ
まとめるとパフォーマンス観点では次のようになります。
- useMemo
- 疑わしきものは大体useMemo使っておけばおk
- useCallback
- 子に関数の参照を渡す場合は useCallback 使っておく。そうでない場合は使っても意味ない
余談: メモリに関して
ちなみに何ですがメモ化と聞くと、単純な実行速度や計算量の話だけでなく「メモリをめっちゃ食ってしまうことはないの?」と疑問を抱く方がいらっしゃるかもしれませんが、こと hooks のメモ化に関しては特に問題がありません。
というのも一般的なプログラミングの関数のメモ化とは違い、 hooks のメモ化は直前の値しか記憶していないからです。なので useCallback/useMemo を使っている場合と使っていない場合でメモリの使用量は変わりません。
試しに先ほども登場した useCallback のアップデート処理を見てみましょう。
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
// deps が変わっていない場合は前回の State を返す
if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
}
// 変わっている場合は新たな State に上書きして返す
hook.memoizedState = [callback, nextDeps];
return callback;
}
ご覧の通り hook.memoizedState
は一つだけです。これは deps が変わった時に「上書き」されます。このため、メモ化によるメモリの使い過ぎについては特に心配する必要はありません。
2.可読性
次に可読性です。当然ですが useCallback/useMemo を使うと1個包む関数が増えるので普通に記述するよりいささか冗長になります。
const hoge = () => console.log('fuga')
const hoge = React.useCallback(() => console.log('fuga'), [])
うん、微妙に読みづらいですね。
ただ、書いといてなんですが、個人的にはこの観点どうでもよいと思っています。他の観点の方がより本質的な課題なのでこれが決め手になるというのはあり得ないなと。ただ気まぐれに useCallback/useMemo 使っているところとそうでないところが散乱しても気持ち悪いので、方針はちゃんと揃えておきたいなという気持ちです。
3.バグの発生しやすさ
これは実体験からの感想ですが、useCallback/useMemo(特に useCallback の方)を使う方がバグが増えます。
理由としては useCallback を使った際に dependencyList をちゃんと書いていないと、古い値を保持してしまうためです。
ちなみにかの Dan 先生はこのような値の保持を stale closures と呼んでいます。直訳で古いクロージャという意味ですね。
So far it seems like the biggest confusion point with Hooks is stale closures. It’s also the one missing in the docs. In hindsight we should have documented that in detail. But with a new paradigm it’s a bit difficult to know what ends up an issue in practice.
— Dan Abramov (@dan_abramov) 2019年2月22日
もし全く useCallback/useMemo を使わない場合は、パフォーマンス改善はないもののとりあえず常に最新の値を参照してくれるため、また、多くの人はこの挙動を期待したメンタルモデルでプログラミングしていると思うので、意図しない挙動は減る印象です。
防ぐ方法
上記のようなバグを防ぐための基本的なプラクティスとしては「useCallback/useMemo の中で参照する要素は全て deps に書く」です。とりあえずそうしておけば必ず最新の値には保たれます。
また、このリスクを軽減する方法として eslint の exhaustive-deps
があります。
[ESLint] Feedback for 'exhaustive-deps' lint rule · Issue #14920 · facebook/react · GitHub
この lint rule を入れれば、 deps が必要な useXXX の関数を使った時に、 deps に列挙されていない要素があれば warning を出してくれるようになります。
小ネタ: deps に書きたくない時もある
基本的には全部列挙した方がいいというのはそうなんですが、あえてそうしたくない場合もあります。
分かりやすいのが古くは componentDidMount でやっていたような「Mount時に一回だけ」やりたいような処理は useEffect を使いつつも deps には何も入れたくありません。それ以外にも小技として useCallback 内で useState の set系の関数を実行する時に、引数に関数を入れ、deps には何も入れないことによって関数の再生成を抑えることができます。
詳しくはこちらの記事にまとまっていますが、この記事でも軽く解説します。
React Hooks、useStateの更新関数引数には関数を - Qiita
まずは普通に deps に定義した例
function Hoge() {
const [count, setCount] = React.useState(0)
const increment = React.useCallback(() => {
setCount(count + 1)
}, [count])
return (
<div>
<Button onClick={increment}>ふやす</Button>
{count}
</div>
)
}
これでも動きはしますが、 count
がアップデートされる度に関数が生成されてしまいます。
useCallback 内では以前の状態と相対的に状態を更新しているだけです。そして useState の更新関数では関数を引数に与えると、その関数の引数に現在の状態が入ってきます。これを利用することによって次のような書き方でも意図通り動くようになります。
function Hoge() {
const [count, setCount] = React.useState(0)
const increment = React.useCallback(() => {
setCount(_count => _count + 1)
}, [])
return (
<div>
<Button onClick={increment}>ふやす</Button>
{count}
</div>
)
}
こうすることによって、useCallback での関数生成はマウント時に1回きりだけでよくなります。
なにが言いたいかというと、「deps に全て列挙する」が常に正しいわけではなく、意図的に列挙しない指定しないケースもいくつかあるのでルールは柔軟にしていきたいね、ということです。この辺も型化して自動で判定していけるようにできればいいですね。
結論
長々と語ってきましたが、結論としては次のような感じで書いていくのがいいかなと思います。
- useCallback/useMemo が効果を発生する状況をちゃんとチームで周知する
- 自動化で判定するのは現状難しそうなので、知識共有して個々で適材適所使いどころを判断する
- eslint の ehaustive-deps を可能であれば利用する
- なんらかの形で「参照している要素を deps に指定する」を実現できればよい
- ただしあえて指定したくないケースもあるので柔軟に対応する
"適材適所"というフワフワした結論にはなってしまいましたが、ご意見あればぜひコメントください!
お読みいただきありがとうございました🙇♂️