皆さんこんにちは。筆者の以前の記事では、ReactのuseMemoを無駄に使うことによるレンダリング速度のオーバーヘッドがどれくらいかをベンチマークによって示しました。
それによれば、スマートフォンを想定したとしても、useMemoだけで描画に目に見える影響を与える(16msくらいの遅延を発生させる)には万のオーダーのuseMemoが必要なことが分かります。
速度ではなくuseMemoを使うことによるメモリ消費量の増加を気にする声も聞かれましたが、すみませんが筆者はそこまでメモリクリティカルなアプリをReactで書いたことがなく知見に乏しいため、今回はこの記事の対象外となります。
この結果が出たことでuseMemoをいつ使うのかなどという議論には終止符が打たれたかと思いきや、上記の記事の感想などを見ているとまだ喧々囂々です。
そこで、この記事では筆者の考えを皆さんに共有し、いよいよもってこの議論を終わりにしたいと思います。
結論は、今いる関数の外を見に行くな。そのuseMemoが今または将来に役に立つ可能性が1%でもあるなら使えです。
前提知識
useMemoというのは結局レンダリングパフォーマンスの最適化のためのAPIであることが知られています。
そこでまず前提知識として、useMemoがどのようにしてパフォーマンス最適化に寄与するのか復習しましょう。
useMemoは、以前行われた計算の結果をキャッシュし、useMemoが属するコンポーネントが再レンダリングしても、useMemoの第2引数(deps)が更新されていない場合は計算をスキップして以前の結果を使うという機能を持ちます。そこから考えると、重い計算の実行をスキップできることが恩恵に思えます。
const result = useMemo(() => {
return すごく重い計算(計算に必要ななんらかのデータ);
}, [計算に必要ななんらかのデータ]);
しかし、実は本質はそこにはありません。なぜなら、そこまで重い計算をすることはあまり無いからです。筆者個人的には中で関数呼び出しが何個か行われていたりO(N)の処理があったりすれば気持ち的にuseMemoで囲みたくなりますが、正直気休めでありその程度でパフォーマンスが大きく変わるものではありません。本当にレンダリングに影響を与えるレベルで重い計算なら、useMemoの中でやらないでWeb Workerなどを使ったほうが良いでしょう。
では、日々の開発の中で受けられるuseMemoの恩恵は何でしょうか。それは、React.memoと組み合わせてコンポーネントのレンダリングを削減することです。具体的に言えば、同じ中身のオブジェクトを毎回新たに生成しないことがuseMemoの大きな役割です。
// nameとageが同じだったとしても毎回新しいオブジェクトになる
const user1 = { name, age };
// nameとageが変わらない限り同じオブジェクトが使い回される
const user2 = useMemo(() => ({
name, age
}), [name, age]);
React.memoで囲まれたコンポーネントのデフォルトの挙動では、「再レンダリング時に、渡されるpropsが全て以前と同じだったらレンダリングを省略する」というものです。Reactを使用する際はレンダリングの実行時間のうちReactランタイムが結構な割合を占めますから、React.memoを用いてレンダリングを省略することは目に見える効果があります。そのため「Reactアプリのパフォーマンスチューニング」というと基本的にはReact.memoでコンポーネントを囲ってレンダリングを減らしていくことおよびそのために必要な諸々の修正を指します。
例えば、上で生成したuser1
やuser2
を受け取る次のようなコンポーネントを考えましょう。
const ShowUser: React.VFC<{ user: User }> = React.memo(({ user }) => {
return (
<div>
<p>{user.name} ({user.age})</p>
</div>
);
});
このコンポーネントにuser1
を渡していた場合、せっかくのReact.memoの効果が発揮されません。user
に渡されるオブジェクトが毎回別物になるのでReact.memoは別のデータが来たと判断してShowUser
を再レンダリングしてしまうからです。user2
を渡した場合は、中身が変わらない限り同じオブジェクトを渡しているのでShowUser
の再レンダリングが行われません。
実際にはShowUserは小さいコンポーネントなのでReact.memoを使ってもパフォーマンスへの寄与が少ないでしょう。実際のパフォーマンスチューニングではもっと大きなコンポーネントを狙ってReact.memoを使っていくことになります。
以上のことを実際に体験できるCodeSandboxを用意してみました。具体的なコードの説明は省略しますが、この例ではuseTime
によってApp
が秒間10回再レンダリングされるようになっています。デフォルトではuser
がuseMemoによって最適化されているので、ShowUser
はuser
の中身が変わったときだけ再レンダリングされます。一方、この最適化を外すと(useMemoがuseUserForm
の中にあります)、中身が変わっていないのにShowUser
も秒間10回再レンダリングされます(再レンダリングされるとconsole.log
が実行されるようになっていて確かめられます)。
このように、useMemoは、React.memoを使っているコンポーネントに渡されるオブジェクトが変わるのを最小限にするという目的で使うのがもっとも主流のユースケースです。ちなみに、useCallbackに関しても「オブジェクト」を「関数」と読み替えれば全く同じことが成り立ちます。
useMemoをいつ使うのか
ここからが本題です。以上のことから、無駄なuseMemoを配置しないことにこだわるのであれば、「React.memoを使ったコンポーネントに渡されるオブジェクトが生成されるところにuseMemoを差し込む」という戦略を取ることになります。
しかし、そのようなやり方は筆者としてはおすすめしません。なぜなら、開発効率が悪いし設計(カプセル化)の観点からも良くないからです。
例えば、上のCodeSandboxのアプリケーションで最初は最適化が何もされておらず、React.memoによる最適化を行う場合を想定しましょう。まずShowData
をReact.memoで囲みますが、それだけだと再レンダリングが減らないため、user
が再生成されるのを防ぐ必要性があることに気づきます。そして、user
の生成元がuseUserForm
であることを突き止め、そこにuseMemoを差し込んで解決という流れになるでしょう。
このようなやり方は次の理由から良くありません。
- 開発効率が悪い(ひとつReact.memoを差し込むためにプロジェクトの中をかけずり回る必要がある)
- 設計が悪い(
useUserForm
の内部実装がuseUserForm
を使う側の都合で決められている)
筆者のおすすめのやり方は、「useUserForm
を書く時点で何も考えずにuseMemoを書いておく」ことです。確かに、そのuseMemoが将来的に役に立つか(React.memoを使うコンポーネントの再レンダリングを削減するのに活用されるかどうか)は不明であり、役に立たない場合は無駄なuseMemoとなります。しかし、もし将来的に役に立った場合はそのときの開発効率に寄与することになります。このような、言わば“予防的なuseMemo”を筆者としては推奨します。ランタイムの0.1マイクロ秒程度のオーバーヘッドを対価に将来の開発効率悪化を防止できるのなら万々歳ではないでしょうか。もちろんランタイムに債務を押し付けすぎるのは良くありませんが、そもそもReactなどを使っている時点である意味ランタイムにかなりの部分を押し付けていますから今さらです。
また、そもそも実際に役に立つかどうかに関わらず、カスタムフックの外に渡すオブジェクトを適切にuseMemoで囲うことはお行儀のいいカスタムフックであるために必要なことだと考えています。そもそもReactからデフォルトで提供されるフックも、最大限のお行儀の良さで提供されています。例えば、useState
やuseReducer
から得られるステート更新関数は常に同じオブジェクトです。
極端な話、ライブラリとしてカスタムフックを提供する場合、もしそのライブラリの中のuseMemoが足りないせいでパフォーマンス改善ができないとなったらもうお手上げです。ですから、ライブラリならば予防的に最大限useMemoを使用しておくことは必須です。これがお行儀の良さということですね。そしてそれは、もしあなたが「再利用可能性」とか「適切なインターフェース」といったことを考えながら普段のコードを書いているならば、プロジェクト内のカスタムフック等にも当てはまるはずです。再利用可能なコードは、それを使う側の個々の都合でいちいち内部実装を変えるものではありません(必要に応じた機能追加などはあり得ますが、それはインターフェースの変更でありもはや内部実装の変更ではありません)。
ここではuseUserForm
の例に合わせてカスタムフックの話をしましたが、カスタムフックではなくコンポーネント内で直に使うuseMemoについても同様です。そのようなuseMemoによって作られたオブジェクトは子コンポーネントに渡されることになります。あとからuseMemoを加えることは「子コンポーネントの都合で親コンポーネントを変える」ことになりますから、やはり設計等の観点から良くありません。
さすがに使わない場合はいつか
以上のように、筆者としては今役に立たないとしても、将来的に役に立ちそうであればどんどんuseMemoを書くことを推奨しています。では逆に、useMemoを書かなくてもいい場合はどんな場合でしょうか。それは、将来的に役に立たないことが確定的に明らかな場合です。
例えば、次のようなuseMemoはさすがに余計でしょう。
// このuseMemoは余計
const ageNum = useMemo(() => Number(age) || 0, [age]);
これはそもそもNumber(age) || 0
が全然重くない計算なのでキャッシュする意味が無いこと、そしてオブジェクトを生成する計算ではないのでそちらの利点もありません。明らかに無駄です。
また、オブジェクトを生成する計算であっても次のような場合は筆者はuseMemoを使いません。
// このuseMemoも余計
const style = useMemo(() => ({
display: 'flex',
flexFlow: 'row nowrap',
}), []);
// ...
return <div style={style}>...</div>;
この場合、オブジェクトひとつ生成するという計算自体のオーバーヘッドは低いのでそれをuseMemoで囲もうとは思いません。また、今回style
オブジェクトはdivに渡されることが明らかです。実は、divのようなネイティブのHTML要素に対しては毎回別のオブジェクトを渡してもパフォーマンス上の問題がありません。そのため、ここではuseMemoを使わないことを選択しています。
注意として、もし以下のコードならば筆者はuseMemoを使用します。
// このuseMemoは余計ではない!
const style = useMemo(() => ({
display: 'flex',
flexFlow: 'row nowrap',
}), []);
// ...
return <MyComponent style={style}>...</MyComponent>;
この場合はオブジェクトが渡す先がdivではなく別のコンポーネントです。この場合は別のオブジェクトを生成しないことに価値がある可能性があるので、お行儀よくオブジェクトの再生成を回避します。
ポイントは、MyComponent
の実装をわざわざ確かめに行かないということです。今必要なかったとしても将来的にReact.memoが追加されるかもしれないし、MyComponent
からさらに別のコンポーネントにstyle
がバケツリレーされるように変わるかもしれないし、将来的にuseMemoが必要とされる可能性を否めないからです。useMemoを予防的に書くことは将来への投資ですから、今わざわざ時間をかけるべきではありません。
このことから、useMemoを書くかどうかは今いる関数の中だけ見て決める(関数の外をわざわざ見に行かない)という考え方につながります。関数というのはひとつの開発単位であり、関数を実装するときにその関数全体に視野を広げるのは普通のことですが、関数の外まで視野を広げるべきではありません。それでは関数を分けた意味がかなり薄れるからです。
以上のように、将来の開発効率に投資するための予防的なuseMemoというのが筆者の考え方です。
まとめ
useMemoは、今書いている関数の中だけを見て明らかに無駄でなければどんどん書きましょう。今書いている関数の外のことまで考慮し始めるのは開発効率が悪いし設計も悪いです。もし書いたuseMemoが将来的に役に立てば大きなメリットになりますが、役に立たなかったとしてもそのコスト(オーバーヘッド)はわずかです。
余談: タイトルに「僕の」と入っているのを見ると一部の箱庭諸島にあった「僕の引越し」コマンドを思い出しますよね。