【React】useCallbackの適切な使い方とパフォーマンス

はじめに

こんにちは、システムエンジニアの蛸井です。
最近は React のパフォーマンスチューニングにハマっており、どのようなコードを書くのが最適なのか気になったため色々と調べてきました。
今回の記事では、パフォーマンスチューニングの中でも React Hooks の useCallback に絞って、適切な使い方・使い時について詳しく解説します。

useCallback とは

React Hooks の useCallback について解説します。

公式ドキュメントには以下のように記載されています。
https://ja.reactjs.org/docs/hooks-reference.html#usecallback

メモ化されたコールバックを返します。
インラインのコールバックとそれが依存している値の配列を渡してください。useCallback はそのコールバックをメモ化したものを返し、その関数は依存配列の要素のいずれかが変化した場合にのみ変化します。これは、不必要なレンダーを避けるために(例えば shouldComponentUpdate などを使って)参照の同一性を見るよう最適化されたコンポーネントにコールバックを渡す場合に便利です。

メモ化というのは、呼び出しの結果をキャッシュしておき、再度同じ入力が発生したときにキャッシュを再利用することにより再計算を防ぐという手法です。
つまり、適切にメモ化を行うことにより、プログラムの実行速度を向上させられます。

useCallback の構文は以下のようになっており、

useCallback(関数, 依存配列)

実際の書き方はこのようになります。

const memoizedCallback = React.useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b]
);

第二引数の [a, b] の部分が依存配列であり、この配列に格納された要素のいずれかが変更されたときに関数が再生成されます。

依存配列の要素に変更がなければ、コンポーネントが再レンダリングされたとしてもこの useCallback は同じ関数を返します。

React.memo とは

次の章で詳しく話しますが、useCallback と深くかかわっている React.memo についても軽く解説します。
React.memo とは、コンポーネントをメモ化する関数です。

https://ja.reactjs.org/docs/react-api.html#reactmemo

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

コンポーネントのレンダリング結果をキャッシュしておき、コンポーネントに渡された props が変わらなかった場合にそのコンポーネントのレンダリングをスキップし、キャッシュしたレンダリング結果を再利用します。

つまり親コンポーネントが再レンダリングされたときに、React.memo 化した子コンポーネントの props に変化がなければ子コンポーネントの再レンダリングをスキップできるため、パフォーマンスが向上します。

useCallback はどういう時に使うべきか

結論から先に書くと、useCallback は基本的に、先ほど解説した React.memo と併用して使います。
React.memo 化したコンポーネントに関数を渡す際、useCallback でメモ化した関数を渡すことでパフォーマンスが向上します。

公式の useCallback の説明文を再掲します。
https://ja.reactjs.org/docs/hooks-reference.html#usecallback

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

useCallback を使う一番の目的は 不要なレンダーを避けるため です。
ちなみにこの 参照の同一性を見るよう最適化されたコンポーネント というのが React.memo 化したコンポーネントになります。

React.memo 化したコンポーネントは、呼び出された時に props に変更がないかチェックを行い、変更がなかった時に再レンダリングをスキップします。

ここで重要になってくるのが props に変更がないかのチェックの方法です。

React では、等価性の比較を Object.is 関数により行っています。(=== 演算子とだいたい同じ)
React.memo の props の比較だけでなく、useEffect や useCallback, useMemo の依存配列の比較などにも Object.js が使用されています。

Object.is で値が同一であるかどうかをチェックしますが、オブジェクトや配列や関数などのオブジェクト型のもの (プリミティブではないもの) を比較する場合、その値そのものを比較するのではなく参照先の情報を比較します。
詳しくは オブジェクト参照とコピー

つまり、同じ key, value が入ったオブジェクトや同じ値が入った配列、同じコードで書かれた関数であっても、再レンダリング前と再レンダリング後ではそれらは完全に別物として扱われます。

これによりどういった問題が起こるのかというと、React.memo 化した子コンポーネントにオブジェクト型のもの (今回の例では関数) を渡している場合、親コンポーネントが再レンダリングされたときに props が変わったとみなされ、必ず再レンダリングが発生してしまいます。

const ParentComponent = () => {
  const sampleFunction = () => {}

  return <ChildComponent sample={sampleFunction} >
}

const ChildComponent = React.memo((props) => {
  /* 省略 */
});

このようなケースでは React.memo を使用している意味が全くありません。
ではどうすれば良いのかというと、useCallback を使います。

const ParentComponent = () => {
  const sampleFunction = React.useCallback(() => {}, [])

  return <ChildComponent sample={sampleFunction} >
}

const ChildComponent = React.memo((props) => {
  /* 省略 */
});

useCallback を使用することにより、依存配列の中身が変わらなかった場合メモ化された関数、つまり再レンダリング前と同一の関数が返るため、この関数を渡している子コンポーネントで props が変化していないとみなされて再レンダリングがスキップされます。

これが、この章で最初に記載した useCallback は基本的に React.memo と併用して使う という理由です。

その他の使い方としては、useEffect や useMemo, useCallback などの依存配列に関数を入れる時です。

useEffect(() => {
  doSomething()
}, [doSomething])

依存配列に関数を入れる場合、useCallback した関数を入れてあげると不要な実行を防げます。

非推奨ですが、useCallback せずにそもそも依存配列に関数を渡さないという手もあります。(eslint を導入している場合、ルールによっては警告が出たりする)

useEffect(() => {
  doSomething()
}, [])

useEffect などの実行は、レンダリングと比較してかかるコストが非常に小さいため、ここまでして再実行を防ぐ必要はあまりないと思っています。

いずれにせよ、不要なレンダーを防ぐことが useCallback を使用する一番の目的です。

どんな時でも使って良いのか

ここでは、単に関数自体の再生成を防ぐ目的で useCallback を使用してパフォーマンスが向上するかどうかについて解説します。

const MyComponent = () ={
  const memoizedCallback = useCallback(
    () => {
      doSomething(a, b);
    },
    [a, b],
  );

  return (
    /* 省略 */
  )
}

上記の例の場合、MyComponent コンポーネントが再レンダリングされるたびに useCallback が呼び出され、(依存配列が変わらなければ) memoizedCallback には毎回同じ関数オブジェクトが格納されます。

ただし、キャッシュした結果を再利用するとはいえ、インライン関数部分はレンダリングのたびに毎回作成されます。

() => {
  doSomething(a, b);
}

また、依存配列の中身をチェックする作業だったり、メモ化をするという処理自体にコストがかかるため、上記の例ではそのコストに見合うだけの効果はありません。

そのため結論としては、関数自体の再生成を防ぐ目的で useCallback を使用してもパフォーマンスが向上することはなく、決してどんな時でも useCallback を使用して良いとは言えません。

この話は以下のサイトで詳しく解説されています。
https://prateeksurana.me/blog/when-should-you-memoize-in-react/#usecallback

そもそも使い時をしっかり考えて開発すべきなのか

実際に計測したわけではないですが、何も考えずに useCallback を使用した場合、おそらくパフォーマンスは悪化します。
ただしこれによるパフォーマンスの悪化は本当に誤差レベルなため、特に問題があるわけではありません。

どちらかといえば、メモ化用の関数でラップする分コードが若干複雑になってしまうことのほうが問題であると個人的に思ってます。

React はデフォルトで非常に高速で動作するため、メモ化によって再レンダリングを数回防いだ程度ではほぼ何も変わりません。

たいていの場合メモ化による最適化は気にしなくてよく、このような最適化をするよりも他のことに時間を割くべきだといった声もあります。

アプリケーションが重くなってきて初めてメモ化による最適化を考える、といった感じで良いのではないかと考えています。

さいごに

結論としては、不要なレンダーを防ぐことが useCallback を使用する一番の目的であり、useCallback は React.memo 化したコンポーネントに関数を渡す場合にのみ使えば良いことが分かりました。

調べていく中で、そもそもしっかりとしたパフォーマンスチューニングを行う必要はないといった声がちらほらあったのは意外でした。