🤔

useCallbackは、本当にパフォーマンスを向上させる?

9 min read

始め

すべての始まりは「When to useMemo and useCallback」という記事でした。これを見て「あれ?useCallbackってパフォーマンス向上するやつじゃなかったの?」混乱し、いろいろリサーチしたので、整理したいと思います。


1. 基礎知識

  • Reactコンポーネントは自分のstateが変更された場合、渡されるpropsが変更された場合に再レンダリングされる。
  • 親コンポーネントが再レンダリングされると子コンポーネントも一緒に再レンダリングされる。この時子コンポーネントが最適化されていなかったら、親から渡されるpropsに変更がなくても基本的に再レンダリングされる。
  • コンポーネントが再レンダリングされると、その中で宣言されている関数や変数は以前保存されていたメモリを空けて新しいメモリに再び保存される(garbage collection)。

2. useCallback

useCallbackメモ化されたコールバックを返すHookです。

  • メモ化とは?
    コストが高い呼び出しの結果を保存し、同じ入力が再び発生したときにキャッシュされた結果を返すことによってプログラム実行速度を向上させる技術
const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);

インラインのコールバックとそれが依存している値の配列を渡してください。useCallback はそのコールバックをメモ化したものを返し、その関数は依存配列の要素のいずれかが変化した場合にのみ変化します。

早速useCallback公式ドキュメントからサンプルコードと説明を持ってきました。

このサンプルコードだと、第1引数で渡してる関数はabが変更される時だけメモ化に変更が起きるとうことですね。空の配列[]にしたら最初にレンダリングされる時だけメモ化されます。useEffectに似てる構造でわかりやすいです。

そして今回話したい部分はこの説明の次に出てくる部分です。

これは、不必要なレンダーを避けるために(例えば shouldComponentUpdate などを使って)参照の同一性を見るよう最適化されたコンポーネントにコールバックを渡す場合に便利です。

useCallbackを使う目的が「不必要なレンダーを避けるため」ということがわかります。

大事なのは次の太字の部分です。これは逆に言うと「参照の同一性を見るよう最適化されたコンポーネントにコールバックを渡す場合じゃないと別に要らない」という意味でもあります。

参照の同一性を見るよう最適化されたコンポーネント」って一体何やねんと思うかもしれませんが、ゆっくり何なのかを話していきましょう。

3. 参照の同一性を見る

まずこれを見てください。

//false
[] === []
{} === {}
(() => {}) === (() => {})

//true
0 === 0
"string" === "string"
true === true
false === false

上も下も同じ値を===で比較しているのに、上はfalseで下はtrueです。なぜか分かりますか?

その理由は、上はオブジェクト型で下はプリミティブ型だからです。Javascriptのデータタイプについては以前投稿した「シャローコピー・ディープコピーとは」で紹介していますので、ご参考ください。

===で比較する時、プリミティブ型は値が同じかどうかを見ます。つまり見た目が全く同じならtrueになります。ですが、オブジェクト型はメモリアドレスが同じかどうかを見ます。つまり同じメモリに保存されてなかったら見た目が全く同じでもfalseになるということです。

参照の同一性を見るということはオブジェクト型を===で比較した時にtruefalseかをチェックするという意味でしょう。

ここで最初に出た基礎知識を思い出してみます。

  • Reactコンポーネントは自分のstateが変更されたり、親コンポーネントから渡されるpropsが変更された場合再レンダリングされる。
  • コンポーネントが再レンダリングされると、その中で宣言されている関数や変数は以前保存されていたメモリを空けて新しいメモリに再び保存される(garbage collection)。

今説明した浅い比較とこの基礎知識2つを合わせて分かる事実があります。 「When to useMemo and useCallback」記事の例を借りてきて説明します。

const CountButton = function CountButton({ onClick, count }) {
 return <button onClick={onClick}>{count}</button>;
};

function DualCounter() {
 const [count1, setCount1] = React.useState(0);
 const increment1 = () => setCount1(c => c + 1);
 const [count2, setCount2] = React.useState(0);
 const increment2 = () => setCount2(c => c + 1);
 return (
   <>
     <CountButton count={count1} onClick={increment1} />
     <CountButton count={count2} onClick={increment2} />
   </>
 );
}

ボタンを押したらカウンターの数字が上がる簡単な例ですね。ここで上のCountButtonを押したときに起こることを整理してみましょう。

  1. DualCounterのstateであるcount1が変更される
  2. DualCounterが再レンダリングされる
  3. DualCounterの中の変数や関数(count1setCount1increment1など)たち全部がもともと保存されてたメモリを空けて新しいメモリに保存される
  4. CountButtonは引数で渡される変数と関数の変更チェック
  5. increment1increment2がオブジェクト型のため、保存されたメモリが変わったこどで新しいやつだと判断
  6. CountButton両方とも再レンダリングされる。

…という一連の過程が起きます。結局一つのCountButtonを押しただけなのにCountButtonが全部再レンダリングされてしまいます

直接確認してみましょう。分かりやすくするためにCountButtonconsole.log("Rendered!")を入れましたので、console窓を見てください。一つのボタンを押しただけでconsoleの数字が2ずつ上がることがわかりますね。

もしこのCountButtonがものすごく重かったり数が1000個ほどあったりしたら性能に良くない影響を与えるかもしれません。

4. 最適化の理由

ここで、「引数で渡す関数をuseCallbackで囲んでメモ化したら、子コンポーネントが同じものだと認識して再レンダリングされないのでは?」と思うかもしれません。やってみましょう。

const CountButton = function CountButton({ onClick, count }) {
  return <button onClick={onClick}>{count}</button>;
};

function DualCounter() {
  const [count1, setCount1] = React.useState(0);
  const increment1 = React.useCallback(() => setCount1(c => c + 1), []);
  const [count2, setCount2] = React.useState(0);
  const increment2 = React.useCallback(() => setCount2(c => c + 1), []);
  return (
    <>
      <CountButton count={count1} onClick={increment1} />
      <CountButton count={count2} onClick={increment2} />
    </>
  );
}

このようにincrement1increment2useCallbackに囲みました。これで押したボタンだけ再レンダリングされる…でしょうか?

残念ながら、まだすべてのCountButtonたちが再レンダリングされてます。なぜでしょうか?これは基礎知識の2番をみたら答えが出ます。

  • 親コンポーネントが再レンダリングされた時も一緒に再レンダリングされる。子コンポーネントが最適化されていなかったら、親から渡されるpropsに変更がなくても基本的に再レンダリングされる。

そうです。今のCountButtonたちは渡される引数に変更がなくても親であるDualCounterが再レンダリングされたので自分自身も再レンダリングされたのです。これが子コンポーネントを最適化しなければならない理由です。

5. React.memo

子コンポーネントを最適化する方法としてReact.memoを紹介します。同じく公式ドキュメントからサンプルコードと説明を持ってきました。

const MyComponent = React.memo(function MyComponent(props) {
  /* render using props */
});

React.memoパフォーマンス最適化のための高階コンポーネント(HOC, higher-order component)です。

高階コンポーネントが何かと言うと、コンポーネントを引数としてもらって新しいコンポーネントを返す関数です。React.memoの場合は引数としてコンポーネントをもらい、最適化されたコンポーネントを返してくれるでしょう。

説明の続きです。

もしあるコンポーネントが同じ props を与えられたときに同じ結果をレンダーするなら、結果を記憶してパフォーマンスを向上させるためにそれを React.memo でラップすることができます。つまり、React はコンポーネントのレンダーをスキップし、最後のレンダー結果を再利用します。

React.memo は props の変更のみをチェックします。

要するにReact.memoでコンポーネントを囲んだら、渡された引数に変化があるかどうかチェックして変化がある場合のみ再レンダリングされる機能が追加されます。

const CountButton = React.memo(function CountButton({ onClick, count }) {
  return <button onClick={onClick}>{count}</button>
})

function DualCounter() {
  const [count1, setCount1] = React.useState(0)
  const increment1 = React.useCallback(() => setCount1(c => c + 1), [])
  const [count2, setCount2] = React.useState(0)
  const increment2 = React.useCallback(() => setCount2(c => c + 1), [])
  return (
    <>
      <CountButton count={count1} onClick={increment1} />
      <CountButton count={count2} onClick={increment2} />
    </>
  )
}

このようにCountButtonReact.memoで囲んだらなんと、クリックしたやつだけ再レンダリングされます!これがまさに「参照の同一性を見るよう最適化」ですね。

ここで「useCallback使わずにReact.memoだけやっといたらどうなる?」と思うかもしれません。参照の同一性の部分を思い出してみて、useCallbackなしでReact.memoだけある場合に起きることを整理して見ましょう。

  1. DualCounterが再レンダリングされる
  2. DualCounterのなかの関数たちは新しいメモリに保存される
  3. その関数を渡されたReact.memoが以前のやつと比較する
  4. メモリアドレスが変わってるから変更されたやつだと判断する
  5. CountButtonも全部再レンダリングされる

という過程で結局この場合もCountButtonたちが全部再レンダリングされてしまいます。

つまり、子コンポーネントが「メモ化された関数(オブジェクト型)を引数に渡される」かつ「自分も最適化もされてる」場合に子コンポーネントの不要な再レンダリングを防げます。

6. 良くない使い方

以前、あるReactチュートリアルで「コンポーネントの中で宣言されてる関数はコンポーネントが再レンダリングされる度に再生成されるからuseCallbackで囲んだほうが良い」と聞いたことがあります。これが良くない使い方です。

私たちは既に上のCountButtonの例でuseCallbackだけ使ったら子コンポーネントの再レンダリングを防げないことを学びました。useCallbackの目的である「不必要なレンダーを避ける」ができてないのによい使い方のはずがないでしょう。

それに関数宣言はコストが安い処理なので、わざわざuseCallbackまで使って防げるべきものではありません。

useCallbackを使うことにもコストがかかりますuseCallbackというHookを読み込むし、第2引数で配列の宣言もするし、レンダリングの度にuseCallbackが動きます。useCallbackを使ったほうが得な時もあしますが、場合によっては逆に余計なメモリを食う時だってあります。

7. まとめ

When to useMemo and useCallback」の著者であるKent C. Doddsはこう言いました。

MOST OF THE TIME YOU SHOULD NOT BOTHER OPTIMIZING UNNECESSARY RERENDERS. React is VERY fast and there are so many things I can think of for you to do with your time that would be better than optimizing things like this.
ほとんどの場合、不要なレンダリングの最適化は気にしなくていいです。Reactは非常に速いし、このようなものを最適化するよりも、他に時間を割いてやるべきことはたくさんあります

そして今まで話した最適化の必要性も極稀と言ってます。彼がPayPalで働いた3年間、そしてそれよりも長いReact歴の間もそいういう最適化が必要な瞬間はなかったようです。

要するにuseCallbackを使うべき瞬間はそんなに多くないということになります。それなのに必要でもないHookを「とりあえず入れとこう」という気持ちで使うことはやめたほうがいいでしょう。


終わり

今回はuseCallbackに集中して話しましたが、useMemoも使ったほうが得な時だけ!使いましょう。公式ドキュメントのサンプルコードでもuseMemoに渡す関数の名前がcomputeExpensiveValueなぐらいです。

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

難しい内容でしたが、知らなかったことをたくさん勉強できてうれしいです🤗

この記事に贈られたバッジ

Discussion

ログインするとコメントできます