🔄

Reactでメモ化したコンポーネントに同じpropsを渡しているはずなのに不要な再レンダリングが行われた例

2024/11/10に公開

はじめに

以前、Reactでメモ化したコンポーネントを作成しました。その際、propsに同じ値を渡しているはずなのになぜか再レンダリングされてしまうことがありました。今回は前提知識を踏まえてなぜ再レンダリングされてしまうのかまとめてみました。

コンポーネントのメモ化

Reactでは以下の書き方でコンポーネントをメモ化することができます。メモ化していなくてもChildComponentの表示内容は変わりませんが、親コンポーネントが再レンダリングされるとChildComponentも再レンダリングされます。もしも長さが何千もある配列などがpropsで渡されていた場合はChildComponentの再レンダリングはかなり重い処理になってしまいます。ですが、ChildComponentに渡された配列の内容が変わらなければ再レンダリングする必要はありません。このような場合にmemoを使うことでpropsが変更されていない場合にコンポーネントの再レンダリングをスキップできます。

// メモ化されていないコンポーネント
const ChildComponent = ({ name }: { name: string }) => {
  return (
    <Typography>{`私の名前は${name}です`}</Typography>
  );
}

// メモ化したコンポーネント
const ChildComponent = memo(({ name }: { name: string }) => {
  return (
    <Typography>{`私の名前は${name}です`}</Typography>
  );
});

※TypographyはMUIのコンポーネントでpタグと同じことができます

propsが同じはずなのに再レンダリングされてしまった例

以下の書き方をしたところ毎回同じnameを渡しているはずなのに、ParentComponentが再レンダリングされるたびにChildComponentが再レンダリングされてしまいました。補足ですがsxはMUIでコンポーネントのスタイルを指定する際に使用します。
結論から述べるとsx={{ color: 'red' }}の部分が原因だったのですが、なぜこれが原因でメモ化しているコンポーネントが再レンダリングされるのか順を追ってご説明したいと思います。

// 親コンポーネント
const ParentComponent = () => {
  const name = '太郎';
  return <ChildComponent sx={{ color: 'red' }} name={name} />;
};

// 子コンポーネント
const ChildComponent = memo(({ sx, name }: { sx: SxProps<Theme>, name: string }) => {
  return (
    <Typography sx={{ ...sx }}>{`私の名前は${name}です`}</Typography>
  );
});

memoではどのように同じpropsと判定しているのか

propsの比較はmemoコンポーネントが行ってくれます。memoの第二引数に独自の比較用関数を指定することもできますが、特に指定していない場合はObject.is()を使って比較します。詳細なObject.is() の比較結果の例はこちらを確認していただきたいのですが、オブジェクトについては以下のようになります。なぜ同じプロパティを持つオブジェクトなのに比較結果がfalseになるのか見ていきたいと思います。

Object.is()で同じプロパティを持つオブジェクトを比較するとfalseになる理由

数値、文字列、真偽値などの同じ値をObject.is()で比較すると結果はtrueになります。これらにはプリミティブなデータを比較しているという共通点があります。
ですが、オブジェクトは参照型です。Object.is()では参照が同じかどうかで比較されます。例えば以下のような場合は参照が同じということになります。先ほどの例ではsx1とsx2は参照が違うため同じプロパティを持っていてもfalseと判断されていました。

sx={{ color: 'red' }}はなぜ違うpropsと判断されるのか

本題に入ります。なぜ上記の書き方では再レンダリングのたびに異なるpropsを渡されていると判断されるかという話ですがこの書き方では
・オブジェクト{ color: 'red' }は再レンダリングのたびに作成される
・毎回参照が異なるオブジェクトがpropsに渡される
・memoコンポーネントでObject.is()の結果がfalseとなる
・前回と異なるpropsが渡されたと判断される
ため再レンダリングされてしまいます。

不要な再レンダリングを回避する方法

今回の場合はsxを事前に定義してsx={sx}の形で渡すことで回避できます(↓の修正中)。これで毎回同じ参照のsxが渡されるためChildComponentが再レンダリングされなくなると思いきやまだ再レンダリングされてしまいます。
原因はconst sx = { color: 'red' };もまた再レンダリングのたびに作成されるので、結局毎回異なる参照が渡されてしまうためです。そのためsx自体をuseMemo()でメモ化することで毎回同じ参照を渡すことができるようになります。これでChildComponentの不要な再レンダリングも行われなくなります。

// 親コンポーネント(修正後)
const ParentComponent = () => {
  const sx = useMemo(() => ({ color: 'red' }), []);
  const name = '太郎';
  return <ChildComponent sx={sx} name={name} />;
};

// 親コンポーネント(修正中)
const ParentComponent = () => {
  const sx = { color: 'red' };
  const name = '太郎';
  return <ChildComponent sx={sx} name={name} />;
};

// 親コンポーネント(修正前)
const ParentComponent = () => {
  const name = '太郎';
  return <ChildComponent sx={{ color: 'red' }} name={name} />;
};

余談

今回ご紹介した方法はメモ化したコンポーネントのpropsにオブジェクトを渡す場合に有効です。一方で今回の例に限った話ではcolorを渡せばsx(オブジェクト)をメモ化して渡す必要はなくなります。そのためcolor(string型)のようなプリミティブなデータをpropsで渡すことでオブジェクトを渡す必要はなくなります。Reactのドキュメントにもある通り、メモ化する前に一度メモ化以外の方法で不要な再レンダリングを回避できないかぜひ考えていただければと思います。

Discussion