なんとなくでやらないReact.memo戦略

7 min read読了の目安(約6900字 2

Reactを使っていると、ふと再レンダリングが気になることがあります。
そこでReactの公式サイトやインターネットを見るわけですが、多く場合useCallbackReact.memoを使えという旨の記事が確認できると思います👀
それはわかったとして、どういう戦略でこれらを使い不要な再レンダリングを防ぐべきかという考え方についてはあまり載っていなかった気がするので、せっかくなのでまとめておきます。

ところでどうすると再レンダリングを抑止できるか

Reactはstateが変わった時、その状態によって必要な箇所を再レンダリングします。

export default function App() {
  console.log('App');
  const [count, updateCount] = useState(0);
  const handleUpdateCount = () => {
    updateCount((count) => count + 1);
  };
  return (
    <div className="App">
      <div>{count}</div>
      <button onClick={handleUpdateCount}>increment</button>
    </div>
  );
}

これだとボタンを押されるたびに、countの値が変わるので、incrementボタンを押すたびに常に再レンダリングされるのはわかると思います。

例えば以下のような場合はどうでしょうか?

const ComponentA: React.FC = () => {
  console.log('ComponentA');
  const [count, updateCount] = useState(0);
  const handleUpdateCount = () => {
    updateCount((count) => count + 1);
  };
  return (
    <div>
      <div>{count}</div>
      <button onClick={handleUpdateCount}>increment</button>
    </div>
  )
}

const ComponentB: React.FC = () => {
  console.log('ComponentB');
  return (<div>ComponentB</div>);
}

export default function App() {
  console.log('App');
  return (
    <div className="App">
      <ComponentA />
      <ComponentB />
    </div>
  );
}

この時はincrementボタンを押して更新されるのはComponentAだけです。
直感的ですね🙆🏻♂️‍

では以下のような場合はどうでしょうか?

const ComponentA: React.FC = ({ children }) => {
  console.log('ComponentA');
  const [count, updateCount] = useState(0);
  const handleUpdateCount = () => {
    updateCount((count) => count + 1);
  };
  return (
    <div>
      <div>{count}</div>
      <button onClick={handleUpdateCount}>increment</button>
      <div>
        {children}
      </div>
    </div>
  )
}

const ComponentB: React.FC = () => {
  console.log('ComponentB');
  return (<div>ComponentB</div>);
}

export default function App() {
  console.log('App');
  return (
    <div className="App">
      <ComponentA>
        <ComponentB />
      </ComponentA>
    </div>
  );
}

更新が起こる可能性のあるComopnentAにchildrenでComponentBを渡しています。
この場合もincrementボタンが押された場合、更新されるのはComponentAだけです。

なんとなく、再レンダリングが起こる範囲についてつかめてきた感じがあります。

memoとuseCallback

上記でみたように、基本的にはstateの持ち方やコンポーネントの分割を適切に行うことで再レンダリングについては防げることがわかりました。
だけど現実問題、たくさんのstateを持つコンポーネントが出てくると思います。そしてそのコンポーネントはおそらくその子共達や孫達にstateを分配することになると思います。
その時、子供や孫にとって自分のprops以外の値が更新されたときは再レンダリングさせたくない、というのが人情だと思います。そういう場合に使うのがuseCallbackReact.memoです。

そういうわけで、memoとかuseCallbackとかまだ何もされていないこんなコードを用意しました。codesandboxなりでReact + TypeScript環境を作ってApp.tsxに貼ってもらうと動くと思います。

import "./styles.css";
import React, { useState } from "react";

type CountButtonProps = {
  type: "RootA" | "RootB";
  onClick: () => void;
};

const CountButton: React.FC<CountButtonProps> = ({ type, onClick }) => {
  console.log(`${type}: Button`);
  return <button onClick={onClick}>{type}: increment</button>;
};

type RootProps = {
  count: number;
  onClick: () => void;
};
const RootA: React.FC<RootProps> = ({ count, onClick }) => {
  console.log("RootA");
  return (
    <div>
      <div>RootA: {count}</div>
      <CountButton type="RootA" onClick={onClick} />
    </div>
  );
};

const RootB: React.FC<RootProps> = ({ count, onClick }) => {
  console.log("RootB");
  return (
    <div>
      <div>RootB: {count}</div>
      <CountButton type="RootB" onClick={onClick} />
    </div>
  );
};

export default function App() {
  console.log('App');
  const [countA, updateCountA] = useState(0);
  const [countB, updateCountB] = useState(0);
  const handleUpdateCountA = () => {
    updateCountA((countA) => countA + 1);
  };
  const handleUpdateCountB = () => {
    updateCountB((countB) => countB + 1);
  };
  return (
    <div className="App">
      <RootA count={countA} onClick={handleUpdateCountA} />
      <RootB count={countB} onClick={handleUpdateCountB} />
    </div>
  );
}

構成としては以下のような感じですね

App - RootA - CountButton
    - RootB - CountButton

この状態でCountButtonのどちらかをクリックするとAppのstateが更新されAppが再レンダリング対象となるので、すべてのコンポーネントが再レンダリングされます。問題ないですね。
そこでRootAをメモ化してみようと思います。

const MemoizedRootA = React.memo(RootA);

export default function App() {
 ...
  return (
    <div className="App">
      <MemoizedRootA count={countA} onClick={handleUpdateCountA} />
      <RootB count={countB} onClick={handleUpdateCountB} />
    </div>
  );
}

しました。ただ、これだけだと変化はありません。countBが更新された場合でもRootAは更新されてしまいます。
理由はAppが更新されるたびにhandleUpdateCountAが新しく作られてしまうからです。
そういうわけで、handleUpdateCountAuseCallbackで依存配列に指定された値が変更されたときのみ作り直されるようにしたいと思います(今回の場合はそれぞれに依存するものがないので依存配列は空になります)。

export default function App() {
  console.log("App");
  const [countA, updateCountA] = useState(0);
  const [countB, updateCountB] = useState(0);
  const handleUpdateCountA = useCallback(() => {
    updateCountA((countA) => countA + 1);
  }, []);
  const handleUpdateCountB = () => {
    updateCountB((countB) => countB + 1);
  };
  return (
    <div className="App">
      <MemoizedRootA count={countA} onClick={handleUpdateCountA} />
      <RootB count={countB} onClick={handleUpdateCountB} />
    </div>
  );
}

すると以下のような挙動になるはずです

RootAのボタンをクリックしたときはRootARootBともに更新されていますが、RootBのボタンを押したときはRootBのみ更新されていますね🙆🏻♂️‍
ただし、ボタンに渡されるhandleUpdateCountAは依存関係がないので最初に作られてから変更はないにもかかわらず、RootAが更新されるたびにRootACountButtonが毎回更新されています。
理由はCountButtonがmemoされていないので、親であるRootAの更新に伴って再レンダリングされてしまうからです。CountButtonmemoしてやるとボタンは初期以外再レンダリングされなくなります。

再レンダリングを抑えるための戦略

上記で長々と眺めたことから、再レンダリングを抑えるにはいくつか方法があることがわかります。

  • stateを持つ位置を工夫し、変更される範囲を限定する
  • memo等でレンダリングをおさえる

すべてのコンポーネントはmemoするべきですか?あるいはすべての関数はuseCallbackするべきですか?

すべてのコンポーネントをmemoし、すべての関数をuseCallbackすると再レンダリングの回数は最も抑えられるのは間違いなさそうです。
ただ、上記で眺めたとおり、そもそも再レンダリングされないコンポーネントというものも多々あり、全てmemoする必要はなさそうです。

戦略としては、

  1. stateを持つ位置を下層に移す、限定する
  2. それでももし、stateが変更された時の影響範囲が大きいコンポーネントになる場合はstateを持ったコンポーネントとのつなぎ目をmemoする(必要であればuseCallbackも使う)。

で足りるのではないかと思います。
このようにすると、memo等を行う場所を非常に限定することができそうです。
気持ちとしては、どうしても再レンダリングのコストが大きく掛かってしまう場合にmemoを検討する、くらいでいいのではないかなーと思います。

再レンダリングをすべて抑制するというよりは、再レンダリングが意図した形で行われているかに焦点を当てることが大事だと思います。

その他: 各コンポーネントはもらうpropsをできるだけプリミティブ値にする

各コンポーネントでは多くの場合何らかのpropsを受け取ると思います。
公式ドキュメントにもある通り、memoを後々利用することになった場合propsの比較は普通shallow compareで行われます。つまりオブジェクトで渡ってきても中身まで見ないということです。

const a = {};
const b = {};

console.log(a === a); // true
console.log(a === b); // false

みたいなことですね。基本的にReactで扱われる値はimutableなはずなので、そのオブジェクトがどこかで更新された場合は中身は一緒でも違うものとして扱われることになります。
NumberとかStringとかのプリミティブは実際の値が比較されるので、同じ値の場合はtrueになります挙動がわかりやすいですね。

これは変化する値の場合ということなので、もらってからコンポーネントが消失するまでどこからも変更されない値の場合はオブジェクトでもなんでもいいと思います🙆🏻♂️‍

参考