🐰

React.memo の Shallow Comparison 回避方法

2024/06/15に公開
2

概要

今回は、実務の中で若干詰まった Shallow Comparison の回避方法を、忘れないようメモ程度の記事として残しておこうと思います。

簡単な内容のため、ほとんど React の公式ドキュメントの内容を自分のわかりやすい言葉に置き換えているだけなので、さわりだけ見ていただいた後は公式を見る方が安全だと思います。

https://ja.react.dev/reference/react/memo

課題

コンポーネントを memo 化することで、不要なレンダリングを回避することができます。
ですが、今回問題になったのは、その memo 化したコンポーネントが不要なタイミングでレンダリングしてしまうというものでした。

具体的には、以下のようなコードで実装を行なっており、親コンポーネントで子コンポーネント値とは関係のない値の更新を行なっていました。

// 親コンポーネント
import { useState } from 'react';
import MemoChildren from '../../components/MemoChildren';

const Memo = () => {
  const [hoge, setHoge] = useState('hoge');

  return (
    <div>
      <h1>Hello World!</h1>
      <MemoChildren text='text' size={1} isReady list={['list1', 'list2']} />
      <button onClick={() => setHoge('poyo')}>ボタン {hoge}</button>
    </div>
  )
};

export default Memo;
// 子コンポーネント
import { memo } from 'react';

type Props = {
  text: string;
  size: number;
  isReady: boolean;
  list: string[];
}

const MemoChildren = (props: Props) => {
  const { text, size, isReady, list } = props;
  console.log('render');

  return (
    <div>
      <h1>{text}</h1>
      <p>{size}</p>
      <p>{isReady ? 'Ready!' : 'Not Ready...'}</p>
      <ul>
        {list.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  )
};

export default memo(MemoChildren);

rendering

大半の方は記事のタイトルやコードで問題箇所はわかると思います。
問題になっているのは、記事のタイトルに入れている Shallow Comparison (浅い比較)にありました。

原因

では、Shallow Comparison (浅い比較)とはどういうものなのかを簡単にまとめていこうと思います。

簡単にいうと、React.memo を使用したコンポーネントを再レンダリングするかどうかの条件として、Object.is() が用いられているということです。

Object.is()の場合、文字列や数値は値が一致しているかどうかという比較を行なっています。ですが、配列やオブジェクトの場合、参照ベースの比較(実際の値ではなく、格納されているメモリ位置が同じかどうかの比較)になっているため、親コンポーネントのレンダリング毎に変わる値で比較を行うことになってしまっています。

すると、memo化 を行なっているにも関わらず、オブジェクトや配列を props に含んでいることで、毎度再レンダリングが行われることになってしまいます。

そのため、今回の例で挙げた実装だと、子コンポーネントに list: string[] という配列を渡しているため、親コンポーネントがレンダリングされる度に配列のメモリ値が変わり、React.memo の再レンダリングをしないための条件から外れてしまうのでした。

解決

そして、その解決方法は、React.memo 自体が持ってくれていました。

具体的には、React.memo には arePropsEqual という第二引数を受け取ることができ、この関数で実装者側で比較する条件(再レンダリングを行うかどうかの条件)を設定することができます。

https://ja.react.dev/reference/react/memo#specifying-a-custom-comparison-function

type arePropsEqual =  (prevProps: Readonly<ComponentProps<T>>, nextProps: Readonly<ComponentProps<T>>) => boolean

arePropsEqual は2つの引数を受け取るのですが、どちらもコンポーネントが受け取る props を引数に取ります。その2つの props の内容の違いとしては、親コンポーネントのレンダリングが走る前に受け取った props の値と、レンダリングが走ったタイミングで受け取った props の値になります。

そのため、関数内でレンダリング前とレンダリング後の値を比較した boolean の値を返し、props の値がレンダリング前後で異なっている(return false)となった場合は、そのメモ化された子コンポーネントのレンダリングが実行されるということになります。

具体的に、例で挙げたコンポーネントだと以下のような実装になります。

import { memo } from 'react';

type Props = {
  text: string;
  size: number;
  isReady: boolean;
  list: string[];
}

const arePropsEqual = (prevProps: Props, nextProps: Props) => {
  if (prevProps.text !== nextProps.text) {
    return false;
  }
  if (prevProps.size !== nextProps.size) {
    return false;
  }
  if (prevProps.isReady !== nextProps.isReady) {
    return false;
  }
  if (prevProps.list.length !== nextProps.list.length) {
    return false;
  }
  for (let i = 0; i < prevProps.list.length; i++) {
    if (prevProps.list[i] !== nextProps.list[i]) {
      return false;
    }
  }
  return true;
}

const MemoChildren = (props: Props) => {
  const { text, size, isReady, list } = props;
  console.log('render');

  return (
    <div>
      <h1>{text}</h1>
      <p>{size}</p>
      <p>{isReady ? 'Ready!' : 'Not Ready...'}</p>
      <ul>
        {list.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  )
};

export default memo(MemoChildren, arePropsEqual);

rendering

このように、こちら側でメモ化したコンポーネントの再レンダリング条件を定義してあげることで、意図しない挙動が発生するリスクを避けることができます。

まとめ

今回は、自分が実装の中で詰まった点について忘れないよう記事として執筆を行いました。

今回の例で挙げた実装だと、わかりやすくシンプルなコンポーネントになっていますが、複雑なロジックやライブラリを用いた実装を行なっている場合だと、意図しない挙動が発生したときに、原因をそちらに向けてしまいがちになります。(実際、自分が今回の記事を執筆するにあたる問題が発生したとき、ライブラリの問題だと思って多くの時間を費やしてしまいました…)

ですが、問題を別のところに向けてしまった要因としては、自分の React.memo に対しての知識が浅かったことも原因として挙げられます。自分と同じようなミスで時間を費やさないよう、今回の記事を通してメモ化の理解を深めるきっかけになれば幸いです。

最後に、React.memo の React のドキュメントを参照しながら記事としてまとめましたが、間違っている箇所などあれば優しく指摘していただけると嬉しいです。

Discussion

nap5nap5

arePropsEqual

この判定式は狙った判定をするとき以外はdequal等使えば、すっきりできそうかと存じます。

// 子コンポーネント
import { Box, List, ListItem, Typography } from '@mui/joy';
import { dequal } from 'dequal';
import { memo } from 'react';

type Props = {
  text: string;
  size: number;
  isReady: boolean;
  list: string[];
};

const enableMemo = true;

export const MemoChildren = memo(
  (props: Props) => {
    const { text, size, isReady, list } = props;
    console.count('render');

    return (
      <Box>
        <Typography level="h2">{text}</Typography>
        <Typography>{size}</Typography>
        <Typography>{isReady ? 'Ready!' : 'Not Ready...'}</Typography>
        <List>
          {list.map((item, index) => (
            <ListItem key={index}>{item}</ListItem>
          ))}
        </List>
      </Box>
    );
  },
  enableMemo
    ? (prevProps: Props, nextProps: Props) => {
        return dequal(prevProps, nextProps);
      }
    : () => enableMemo
);

MemoChildren.displayName = 'MemoChildren';
つちのこつちのこ

コメントありがとうございます!!
確認したところ、確かに指摘いただいた形の方が綺麗にまとまりそうな気がします!

こちら自身が書いたコードがReactのドキュメントに記載のある方法なため、既存の内容は残しつつ、いただいたコメントにある内容も確認できるよう対応させていただきます!