パズルを解きながら、Reactのメモ化を理解する

こんにちは。 AG-Boost事業本部の大塚です。

最近、自社ツールのもっさり感を解消するためパフォーマンス改善を行いました。
フロントでは、キャッシュ化による不要な再計算・再レンダリングを抑えることが重要です。

この記事では、メモ化で頻出のReact.memo, useMemo, useCallback等について、使い方と使い所をまとめてみました。 先月遊んでいたTypescriptの型パズルに倣って いくつか問題を用意したので、パズル感覚でお楽しみいただければと思います。

1. 基礎知識

1-1. レンダリングとは

レンダリングとは、以下の2stepで画面を描画することである。

1, componentを再計算して仮想DOMを作る
2, 仮想DOMに前回と差分が生じた場合、差分だけをリアルDOMに反映させる

1-2. 再レンダリングのタイミング

再レンダリングされる条件は主に2つ

  • stateが更新された時
  • 親コンポーネントが再レンダリングされた時

2. メモ化問題4選

2-1. 不要な再レンダリングを止めよう

コード: https://codepen.io/hihsswbw-the-reactor/pen/QWzmKOv
構成:

  • <App />配下に<child1><child2>がある。
  • ボタンを押すとstateが変わりが再レンダリングされる(#1)
  • 親と共に、二つのも再レンダリングされる(#2)

問題:
<Child2>は状態が不変なので、再レンダリングする必要がない。
React.memoを使って、ボタンを押しても<Child2>が再レンダリングされないようにしよう。






答え: child2をReact.memoでラップする。

const Child2 = React.memo((props) => {
  const { text } = props;
  console.error(text);
  return (
    <div>
      child2
    </div>
  );
});

React.memoでラップしたコンポーネントは、レンダリング前後でpropsに変更が無い場合(shallow比較で判定)、再レンダリングをスキップできる。
親のレンダリングがChild2に伝播する(#2)...ここがReact.memoにより免除される。

変更前と変更後のprops比較処理が入る分、結果的に再レンダリングが必要と判断される場合は、逆にパフォーマンスダウンとなります。
再レンダリングの必要性が高いほどReact.memoの有効性は落ちます。
また、使う際は不要なpropsを渡していないかチェックするといいです。

(※) 第二引数で比較用の関数を渡すこともできます。 メンテナンスが難しくなので、使わない方が安全な気がします... ja.legacy.reactjs.org

2-2. 不要な再レンダリングを止めようver2

コード: https://codepen.io/hihsswbw-the-reactor/pen/dywmqQx
構成:

  • 2-1の続き
  • <Child2>に渡すpropsを変えた所、React.memoでラップしているのに、ボタンを押すと再レンダリングされる状態に戻ってしまった。

問題: useMemo, useCallbackを使って、ボタンを押しても<Child2>が再レンダリングされないようにしよう。




答え:

const map = useMemo(() => return {
   name: 'child2',
}, []);
  
const createText = useCallback(() => {
  return 'child2 rendered';
}, []);

関数やオブジェクトは再定義される度に参照が変わる。
そのため、Child2のpropsは「変更された」と判断され再レンダリングが起きる。

useMemoはオブジェクトを、useCallbackは関数をラップしてキャッシュ化することができる。
キャッシュ化して参照が変わらなければ、propsの変更を検知できず、React.memoにより再レンダリングが防止される。

必要に応じて再計算したい場合は、第二引数を利用する。
レンダリング前後で引数の値に変更があれば、再計算されるようになる。
useMemo – React

2-3. 処理が重い変数の再計算を止めよう

コード: https://codepen.io/hihsswbw-the-reactor/pen/LYMdJaK

構成:

  • ボタンを押すと<App>が再レンダリングされて、sumが再計算される。
  • calcSum()は、計算量は多いが常に同じ値を返す。
  • 再レンダリングにかかった時間は、ブラウザのコンソールで確認できます。

問題: sumをキャッシュ化して、ボタンを押した時のもっさり感を無くそう。






答え:

const sum = useMemo(() => calcSum(), []);

初回レンダリングで計算した値を使い回すことで、初回レンダリング以降はsumの計算を省くことができます。
ログを見れば一目瞭然で違いが分かりますね。

前) タイム: 292.240966796875 ms
後) タイム: 1.418701171875 ms

2-4. 再レンダリングが必要ない値はuseRefで管理しよう

コード: https://codepen.io/hihsswbw-the-reactor/pen/dywemLm?editors=1011
構成:

  • inputの入力値がstateに保持される
  • 入力の度にstateが変わるので、<App>が再レンダリングされる

問題: stateではなくrefを使って入力値を管理し、入力中の再レンダリングを防ごう






答え:

const [inputValue, setInputValue] = useState('');
⇩
const inputRef = useRef('');


value={inputValue}
onChange={(e) => {
  setInputValue(e.target.value);
}}
⇩
ref={inputRef}


onClick={() => {
  setOutputValue(inputValue);
}}
⇩
onClick={() => {
  setOutputValue(inputRef.current.value);
}}

inputの入力値はinputRef.current.valueに保持されます。
refは常に同じ参照を持つので、再レンダリングせずに入力値をcurrent.valueに保持できます。 スクロール位置などの、更新頻度が高い値を扱う際に便利です。
Referencing Values with Refs – React

今回はボタンを押して表示を更新する形式ですが、リアルタイムで表示を変える場合、入力の度に再レンダリングが必要となります。 その場合はrefを使うよりも、debounceを使って処理を間引く等のやり方が有効だと思います。

最後に

今回紹介したメモ化については、解説記事が沢山あります。
この問題を解きながら、他の記事と合わせて少しでも理解の助けになればと思いますm(--)m