📝

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

2021/03/08に公開3

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になります挙動がわかりやすいですね。

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

参考

Discussion

nishinanishina

メモ化に関する記事ありがとうございます。
一点気になった点があったのでコメントさせていただきます。

shallow compareで行われます。つまりオブジェクトで渡ってきても中身まで見ないということです。

中身は一緒でも違うものとして扱われることになります。

すみません、自分もきちんと理解をしているわけではないのですが、オブジェクトの場合でもプロパティがプリミティブ型(スカラ値)であれば浅い比較でオブジェクトの中身も検証してくれるという認識でいます。

浅い比較の検証をするのであれば、===ではなく、react-reduxであればshallowEqualを利用しないとうまくいかないのかなと思います。

浅い比較でオブジェクトの検証をする際に気をつけるのは、オブジェクトのプロパティがオブジェクト型(つまり、多重ネスト)のケースかなと思いました。

import { shallowEqual } from 'react-redux';

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

// 厳密等価ではfalse
console.log(a === b); // false

// 浅い比較ではtrue
console.log(shallowEqual(a, b)); // true
import { shallowEqual } from 'react-redux';

const a = { value: "hoge" };
const b = { value: "hoge" };

// プロパティがスカラ値の場合、中身が同じであれば浅い比較でtrue
console.log(shallowEqual(a, b)); // true
import { shallowEqual } from 'react-redux';

const a = {
  value1: "hoge",
  value2: {
    value2_1: "fuga",
    value2_2: "piyo"
  }
};

const b = {
  value1: "hoge",
  value2: {
    value2_1: "fuga",
    value2_2: "piyo"
  }
};

// 多重ネストの場合は中身が同じでも浅い比較でfalseになる
console.log(shallowEqual(a, b)); // false

参考:
https://nishinatoshiharu.com/js-shallow-deep/
https://tech.aptpod.co.jp/entry/2020/06/26/090000

bom shibuyabom shibuya

コメントありがとうございます🙇‍♂️
書き方が非常にわるかったです。この部分での浅い比較で行われるpropsというのは、個々のpropを指していました🙇‍♂️

<RootA count={countA} onClick={handleUpdateCountA} />

の、countonClickのことです。例えばもし、countがオブジェクトだった場合中まで確認されることはないということを伝えたかったのです🙏
その様子をサンプルを作ってみました🙏
https://codesandbox.io/s/shallow-compare-test-llpf9?file=/src/App.tsx

確かにshallowEqual関数に渡されたオブジェクト同士は、一階層分だけ比較されるようですね。
Reactだとこの辺がshallowEqualのコードになるようで、react-reduxのコードとほぼ一緒ですね。
https://github.com/facebook/react/blob/master/packages/shared/shallowEqual.js

memoの更新判定がどこらへんで行われているか自信がないのですが(多分ここ? )、なんとなく挙動から予測するに、shallowEqualには前後のpropsが以下のようなかたちで入ることになると思います。

const prevProps = { count, onClick }
const nextProps = { count, onClick }
shallowEqual(prevProps, nextProps)

そして、countやonClickに関しては、is関数なので===で比較されるようです
https://github.com/facebook/react/blob/73e900b0e78c752d1e045a42ddea8175679ea8f7/packages/shared/objectIs.js#L14

XU ZHONGWEIXU ZHONGWEI

参考になりました。ありがとうございます。
補足させていただきます。

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>
  );
}

この例に関しては

<ComponentA>
    <ComponentB />
</ComponentA>

これはReactにとっては実際に以下になります。

<ComponentA children={<ComponentB />}/>

ComponentAがレンダリングしない限り、ComponetnAのmemorizedPropsのインスタンスが変わらないです。
CompnentAのmemoriziedPropsにComponentBの遺伝子が保存されてます。

ComponentAがレンダリングしない限り、ComponentBの遺伝子が変わることがないです。
ComponentBをレンダリングする時に、以下二つ条件全部当てはまれば、ComponentBをレンダリングしません。

  • ComponentBの遺伝子が変わっていない。
  • ComponentBのContext情報が変わったりしていません。

例にあげたのは、Appは上層Componentだから、レンダリングされてません。そのため、ComponentBの遺伝子も再生成していません。
ComponentBをレンダリング使用する時に、遺伝子変わらないこと & Context情報が変わっていないことによって、ComponentBをレンダリングしないことになりました。ComponetBの子供をそのまま引用することになります。

  • App がレンダリングしたら、React.memoを使わない限り、ComponentBが必ずレンダリングになります。
  • ComponentBの内部持っているstateがあったら、ComponentBが必ずレンダリングになります。
import { useContext, useState, Provider, Consumer, ReactNode } from 'react';
import React from 'react'

const ComponentA: React.FC<{children: ReactNode}> = (props) => {
  console.log('ComponentA');
  const { children } = props;
  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 = () => {

  const [num, setNum] = useState(0)

  console.log('ComponentB');
  return (<div>ComponentB<button onClick={() => setNum(v => v + 2)}>B</button></div>);
}

export default function App() {
  console.log('App');
  const [num, setNum] = useState(0);
  return (
    <div className="App">
      <ComponentA>
        <ComponentB />
      </ComponentA>
      <button onClick={() => setNum((v) => v + 1)}>A</button>
    </div>
  );
}

上記の例は
Aをクリックしたら、Appがレンダリングされ、ComponentBの遺伝子が変わったので、ComponentBのレンダリングに入ります。
Bをくりっくしたら、Appがレンダリングされなく、ComponentBの遺伝子が変わらないですが、ComponetnBのcontext情報が変わりますので、ComponentBのレンダリングに入ります。