React Props親子バケツリレーでのパフォーマンス改善

## Reactのパフォーマンス改善との戦い
現在のReactでは、アプリケーションのパフォーマンスに`useMemo`や`useCallback`や`React.memo`のメモ化を駆使して最適化を行いますが、どれをメモ化するかとかしないだとか結構な頻度で議論になったりするので~~最近正直面倒だと感じて~~大変ですね...
(メモ化するかしないかで議論になるものは大抵どっちにしてもほとんど大差ないのでどっちでも良いと言われればそれはそう)

今回は業務で経験した`Props`の親子バケツリレーで起こる問題とその解決した方法を紹介したいと思います。

_※ 2022年3月29日にReactv18が正式リリースされました_

## アプリケーション作成
とりあえずデモ用に似たようなパターンのアプリケーションをサクッと作りました。
ディレクトリ構成は以下のようになっています。

```
src/
├── components
│   ├── App.tsx
│   ├── Child.tsx
│   └── Grandchild.tsx
├── hooks
│   └── index.ts
├── index.css
├── index.tsx
├── styles.css
└── types
    └── index.ts
```

### デモ処理説明

まず最初に、ダミーデータを取得してState管理する`useDataSource`というカスタムフックを爆誕させます。
```ts:hooks/index.ts
import { useState, useEffect, useCallback } from "react";
import { DataSource, SelectPhoto } from "../types";

export const useDataSource = () => {
  const [dataSource, setDataSource] = useState<DataSource[]>([]);
  const [selectPhoto, setSelectPhoto] = useState<SelectPhoto>({
    userId: 0,
    photo: "",
  });

  useEffect(() => {
    const fetchJsonData: DataSource[] = [
      {
        id: 1,
        name: "山田 太郎",
        address: {
          city: "東京都",
        },
        photos: [
          "https://source.unsplash.com/random/200x200?sig=1",
          "https://source.unsplash.com/random/200x200?sig=2",
          "https://source.unsplash.com/random/200x200?sig=3",
        ],
      },
      {
        id: 2,
        name: "鈴木 次郎",
        address: {
          city: "大阪府",
        },
        photos: [
          "https://source.unsplash.com/random/200x200?sig=4",
          "https://source.unsplash.com/random/200x200?sig=5",
          "https://source.unsplash.com/random/200x200?sig=6",
        ],
      },
      {
        id: 3,
        name: "佐藤 三郎",
        address: {
          city: "北海道",
        },
        photos: [
          "https://source.unsplash.com/random/200x200?sig=1",
          "https://source.unsplash.com/random/200x200?sig=4",
        ],
      },
      {
        id: 4,
        name: "田中 四郎",
        address: {
          city: "沖縄県",
        },
        photos: [
          "https://source.unsplash.com/random/200x200?sig=5",
          "https://source.unsplash.com/random/200x200?sig=6",
        ],
      },
      {
        id: 5,
        name: "高橋 五郎",
        address: {
          city: "福岡県",
        },
        photos: ["https://source.unsplash.com/random/200x200?sig=3"],
      },
    ];
    setDataSource(fetchJsonData);
  }, []);

  const handleSelectPhoto = useCallback((id: number, photo: string) => {
    setSelectPhoto({
      userId: id,
      photo,
    });
  }, []);

  return {
    dataSource,
    selectPhoto,
    handleSelectPhoto,
  };
};
```
ちなみに型はこんな感じ
```ts:types/index.ts
export type DataSource = {
  id: number;
  name: string;
  address: {
    city: string;
  };
  photos: string[];
};

export type SelectPhoto = {
  userId: number;
  photo: string;
};
```
今回はAPIとかではなく直接Jsonデータを入れています。
写真データは、リロードするたびにランダムな画像に切り替わる[こちらのサイト](https://unsplash.com/developers)の画像を使いました。（便利ね）
このユーザーたちは、`id`は一人一人ユニークですが、`photos`の中身の写真URLは他のユーザーと同じものもあったりします。
戻り値で返す`handleSelectPhoto`関数は毎回再生成されないように`useCallback`でラップしメモ化します。


次に一番親である`App.tsx`は、`useDataSource`というカスタムフックを呼び出してそのデータをmapで回して子コンポーネントである`Child.tsx`に渡しています。
```tsx:components/App.tsx
import "../styles.css";
import { Child } from "./Child";
import { useDataSource } from "../hooks";

export const App = () => {
  const { dataSource, selectPhoto, handleSelectPhoto } = useDataSource();
  console.log("親 Component", selectPhoto);
  return (
    <div className="App">
      <div className="parent">
        {dataSource.map((user) => {
          return (
            <div className="child" key={user.id}>
              <Child
                user={user}
                selectPhoto={selectPhoto}
                handleSelectPhoto={handleSelectPhoto}
              />
            </div>
          );
        })}
      </div>
      <div>
        <div>選択中の絵</div>
        <img
          className="selectedPhoto"
          src={
            selectPhoto.photo
              ? selectPhoto.photo
              : "https://suryacipta.com/wp-content/themes/consultix/images/no-image-found-360x250.png"
          }
          alt=""
        />
      </div>
    </div>
  );
};
```
まぁ、よくあるやつですね。

次に、`Child.tsx`はというとまたまた`Grandchild.tsx`に`photos`をmapで回し、渡しています。

```ts:components/Child.tsx
import { FC } from "react";
import { DataSource, SelectPhoto } from "../types";
import "../styles.css";
import { Grandchild } from "./Grandchild";

type Props = {
  user: DataSource;
  selectPhoto: SelectPhoto;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Child: FC<Props> = ({ user, selectPhoto, handleSelectPhoto }) => {
  console.log("子 Component", user.name);
  return (
    <div className="child">
      <div>{`名前: ${user.name}`}</div>
      <div>{`街: ${user.address.city}`}</div>
      <div className="photos">
        {user.photos.map((photo) => {
          return (
            <Grandchild
              key={`${user.id}-${photo}`}
              user={user}
              selectPhoto={selectPhoto}
              photo={photo}
              handleSelectPhoto={handleSelectPhoto}
            />
          );
        })}
      </div>
    </div>
  );
};
```

最後に`Grandchild.tsx`は、対象の写真をクリックすると選択されている写真として赤い枠線が付けられる仕様になっています。
```ts:components/Grandchild.tsx
import { FC } from "react";
import { DataSource, SelectPhoto } from "../types";

type Props = {
  user: DataSource;
  selectPhoto: SelectPhoto;
  photo: string;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Grandchild: FC<Props> = ({
  user,
  selectPhoto,
  photo,
  handleSelectPhoto,
}) => {
  console.log("孫 Component", user.address.city);
  return (
    <div
      className={
        selectPhoto.userId === user.id && selectPhoto.photo === photo
          ? "selectedPhotoWarper"
          : "photoWarper"
      }
    >
      <img
        className="photo"
        src={photo}
        alt="ダミー写真"
        onClick={() => {
          handleSelectPhoto(user.id, photo);
        }}
      />
    </div>
  );
};
```

実際のアプリケーションはこんな感じになりました。
![rifakutamae-2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/598763/e7f93581-a444-84e7-ee6d-18287b8d7943.gif)
なかなかいい感じです。

これのどこが良くないのでしょうか？

実はこのアプリケーションは写真をクリックするたびに親コンポーネントをはじめ、全ての親子コンポーネントが再レンダリングされてしまっています。

![rifakutamae-3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/598763/84ccad91-3994-122b-84b5-0d94a67bcb71.gif)

さぁ、ここからこのアプリケーションのパフォーマンス改善との戦いです。

### バトル開始
まずはとりあえず、手っ取り早く子供コンポーネントたちのメモ化をしちゃいましょう。

コンポーネントをメモ化するには、`React.memo`でコンポーネント本体をまるっとラップします。

```ts:components/Child.tsx
import { FC, memo } from "react";
import { DataSource, SelectPhoto } from "../types";
import "../styles.css";
import { Grandchild } from "./Grandchild";

type Props = {
  user: DataSource;
  selectPhoto: SelectPhoto;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Child: FC<Props> = memo(({ user, selectPhoto, handleSelectPhoto }) => {
  console.log("子 Component", user.name);
  return (
    <div className="child">
      <div>{`名前: ${user.name}`}</div>
      <div>{`街: ${user.address.city}`}</div>
      <div className="photos">
        {user.photos.map((photo) => {
          return (
            <Grandchild
              key={`${user.id}-${photo}`}
              user={user}
              selectPhoto={selectPhoto}
              photo={photo}
              handleSelectPhoto={handleSelectPhoto}
            />
          );
        })}
      </div>
    </div>
  );
});
Child.displayName = "Child";
```

```ts:components/Grandchild.tsx
import { FC, memo } from "react";
import { DataSource, SelectPhoto } from "../types";

type Props = {
  user: DataSource;
  selectPhoto: SelectPhoto;
  photo: string;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Grandchild: FC<Props> = memo(({
  user,
  selectPhoto,
  photo,
  handleSelectPhoto,
}) => {
  console.log("孫 Component", user.address.city);
  return (
    <div
      className={
        selectPhoto.userId === user.id && selectPhoto.photo === photo
          ? "selectedPhotoWarper"
          : "photoWarper"
      }
    >
      <img
        className="photo"
        src={photo}
        alt="ダミー写真"
        onClick={() => {
          handleSelectPhoto(user.id, photo);
        }}
      />
    </div>
  );
});
Grandchild.displayName = "Grandchild";
```
ちなみにコンポーネントの最後に定義している`displayName`はコンポーネントに名前をつけるためです。
`eslint`入れてると、[react/display-name](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/display-name.md)で以下のように怒られます。
```
Component definition is missing display name
```

既に名前ついてんじゃん！と思うかもですが、`React.memo`でコンポーネントをラップすると、`React.memo`のコールバック関数としてコンポーネントを返して元々コンポーネント名であった変数に代入している形になるので実質コンポーネントが名前がない状態になっています。
名前をつけないといけない理由としては、
>デバッグのために、全てのコンポーネントに名前を付けることが推奨されるため

らしいです。

`ReactDeveloperTools`の`Components`を見るとコンポーネント自体がメモ化されていることがわかりますね。
![スクリーンショット 2022-08-28 8.56.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/598763/44e61e63-1373-77aa-2362-4f957b012a65.png)

ただ、実はこの状態では以前の状態と変わりなく、相変わらず全てのコンポーネントがレンダリングされます。

### 何故メモ化しているのに再レンダリングされるのか

`React.memo`は渡される`Props`の等価性（値が等価であること）をチェックして再レンダリングの判断をしていていて、この等価性はどの程度なのかというと
厳密等価演算子(===)で比較しています。
なので、`React.memo`内部では
```ts
// 前回のProps === 現在のProps
prevProps === currentProps
```
上記のようなことがレンダリングのたびに行われているということになります。
この比較でReactが「Propsが変わっているからレンダリング対象だな」と判断して再レンダリングが起きているわけですね。

### どこが変更になっているか調査

今回の場合はそこまで`Props`の値が少ないのでどの値が変更になってると判断されてレンダリングが行われているのがわかりますが、`Props`の値が10個くらいになってくると変更されている値の特定が難しくなってきます。

そこでオススメなのが、この`React.memo`の第二引数で比較関数を取ることができるのですが、その比較関数内で`Props`の中身を一つずつ厳密等価演算子で比較して真偽値が`false`になるものが値が変更になっているものになるのでそれで確かめるという手があります。
ただ、この`React.memo`の第二引数はバグにつながる可能性が高いので必要がない限り定義しないことが推薦されています。なのでこの検証が終わり次第削除する方がよさそうです。

```ts:components/Child.tsx
import { FC, memo } from "react";
import { DataSource, SelectPhoto } from "../types";
import "../styles.css";
import { Grandchild } from "./Grandchild";

type Props = {
  user: DataSource;
  selectPhoto: SelectPhoto;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Child: FC<Props> = memo(
  ({ user, selectPhoto, handleSelectPhoto }) => {
    console.log("子 Component userId", user.id);
    return (
      <div className="child">
        <div>{`名前: ${user.name}`}</div>
        <div>{`街: ${user.address.city}`}</div>
        <div className="photos">
          {user.photos.map((photo) => {
            return (
              <Grandchild
                key={`${user.id}-${photo}`}
                user={user}
                selectPhoto={selectPhoto}
                photo={photo}
                handleSelectPhoto={handleSelectPhoto}
              />
            );
          })}
        </div>
      </div>
    );
  },
  // このReact.memoの第二引数には、前回のPropsと今回のPropsを受け取ることができる
  // 戻り値はboolean型で、trueを返すと再レンダリングを行わない
  (prevProps, nextProps) => {
    console.log("user: ", prevProps.user === nextProps.user);
    console.log("selectPhoto: ", prevProps.selectPhoto === nextProps.selectPhoto);
    console.log("handleSelectPhoto: ", prevProps.handleSelectPhoto === nextProps.handleSelectPhoto);
    return (
      prevProps.user === nextProps.user &&
      prevProps.selectPhoto === nextProps.selectPhoto &&
      prevProps.handleSelectPhoto === nextProps.handleSelectPhoto
    );
  }
);
Child.displayName = "Child";
```

これを実際に操作して検証してみると
![rifakutamae-4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/598763/c8bfb375-79de-6590-dbd5-362e17b9860b.gif)
レンダリングと同時に`React.memo`の第二引数で記載している`console.log`が実行されています。
そして、logの内容ですが

```ts
user: true
selectPhoto: false
handleSelectPhoto: true
```
となっており、現在の`selectPhoto`と前回の`selectPhoto`が変更されていると判断され、レンダリング対象になっているみたいです。（それはそうですが）
ちなみに、`handleSelectPhoto`は`useCallback`でラップしているため、レンダリングのたびに関数が再生成されず同じ関数として扱われます。
もし、`useCallback`でラップしていなかったら`handleSelectPhoto`も常に`false`になります。

ただ、現在、この`selectPhoto`は、孫である`Grandchild.tsx`にも渡さないといけないため、今の設計を改める必要がありそうですね。

### 対応策
原因が判明したため、ここからパフォーマンス改善していきたいと思います。

解決策としては、再レンダリングの原因である`selectPhoto`を変更に関係ない`Child.tsx`に渡さないというのが一番よさそうです。

なので、親である`App.tsx`から`Child.tsx`にPropsに渡す際にやり方を変えてみようと思います。
`App.tsx`を以下のように変更しました。
```ts:components/App.tsx
import "../styles.css";
import { Child } from "./Child";
import { useDataSource } from "../hooks";

export const App = () => {
  const { dataSource, selectPhoto, handleSelectPhoto } = useDataSource();
  return (
    <div className="App">
      <div className="parent">
        {dataSource.map((user) => {
          return (
            <div className="child" key={user.id}>
              <Child
                user={user}
                handleSelectPhoto={handleSelectPhoto}
                // UserIdが一致した場合だけselectPhotoを渡して、その他のChildにはelectPhotoにundefinedを渡す
                {...(selectPhoto.userId === user.id
                  ? {
                      selectPhoto,
                    }
                  : {
                      selectPhoto: undefined,
                    })}
              />
            </div>
          );
        })}
      </div>
      <div>
        <div>選択中の絵</div>
        <img
          className="selectedPhoto"
          src={
            selectPhoto.photo
              ? selectPhoto.photo
              : "https://suryacipta.com/wp-content/themes/consultix/images/no-image-found-360x250.png"
          }
          alt=""
        />
      </div>
    </div>
  );
};
```
実は、コンポーネントに渡す`Props`は直接オブジェクトで渡すこともできるので、`dataSource`で回しているユーザーデータで、現在の選択しているユーザーIDと一致したもののみ`Child.tsx`に`selectPhoto`を渡し、その他の`Child.tsx`にはselectPhotoを`undefined`で渡しています。

こうすることで`selectPhoto: undefined`を渡された`Child.tsx`は`Props`の変更がないと判断され再レンダリングを防ぐことができ、クリックされる度にレンダリングされるのは、前回のクリックされていたユーザー分の`Child.tsx`と新しくクリックされたユーザー分の`Child.tsx`のみになりました。
![rifakutamae-5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/598763/3af34abe-6fe3-6633-b47f-9604fbd9c455.gif)

`selectPhoto: undefined`で渡した`Child.tsx`の`React.memo`で記載している`console.log`は
```ts
user:  true
selectPhoto:  true
handleSelectPhoto:  true
```
と全て`true`になっており、レンダリングスキップ対象と判断されていますね。

### さらに最適化
実はまだ改善の余地がありそうです。
今度はユーザーが持っている全ての`Grandchild.tsx`が再レンダリングされていることが分かります。
![sample-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/598763/89a865b3-8d9e-4c2c-5f0d-9cefb602cd62.gif)
ここもほとんど同じやり方で、`原因調査`を行いそれを踏まえて対策を行います。
今回は変わっているのは`selectPhoto`のみなので`Child.tsx`と同じ方法を取ります。

ただ、今回はユーザーの`photo`データを回しているため、`photo`が一つ一つ特定出来るので`selectPhoto`を渡すのではなく、`isSelect`という真偽値の値で渡したいと思います。

```ts:components/Child.tsx
import { FC, memo } from "react";
import { DataSource, SelectPhoto } from "../types";
import "../styles.css";
import { Grandchild } from "./Grandchild";

type Props = {
  user: DataSource;
  selectPhoto: SelectPhoto | undefined;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Child: FC<Props> = memo(
  ({ user, selectPhoto, handleSelectPhoto }) => {
    console.log("子 Component userId", user.id);
    return (
      <div className="child">
        <div>{`名前: ${user.name}`}</div>
        <div>{`街: ${user.address.city}`}</div>
        <div className="photos">
          {user.photos.map((photo) => {
            return (
              <Grandchild
                key={`${user.id}-${photo}`}
                user={user}
                photo={photo}
                handleSelectPhoto={handleSelectPhoto}
                // 選択されているかどうかのみを渡す
                {...(selectPhoto && selectPhoto.photo === photo
                  ? {
                      isSelect: true,
                    }
                  : {
                      isSelect: false,
                    })}
              />
            );
          })}
        </div>
      </div>
    );
  }
);
Child.displayName = "Child";
```

```ts:components/Grandchild.tsx
import { FC, memo } from "react";
import { DataSource } from "../types";

type Props = {
  user: DataSource;
  isSelect: boolean;
  photo: string;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Grandchild: FC<Props> = memo(
  ({ user, isSelect, photo, handleSelectPhoto }) => {
    console.log("孫 Component photo", photo);
    return (
      <div className={isSelect ? "selectedPhotoWarper" : "photoWarper"}>
        <img
          className="photo"
          src={photo}
          alt="ダミー写真"
          onClick={() => {
            handleSelectPhoto(user.id, photo);
          }}
        />
      </div>
    );
  }
);
Grandchild.displayName = "Grandchild";
```
これで`Child.tsx`と同じく`Grandchild.tsx`も前回の写真と現在の写真のみ再レンダリングされるようになり、かなり最適化されたと思います。
![sample-2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/598763/869614ec-ef82-9229-51f0-ffed06644905.gif)

### 最後に
当時、Reactのことをよくわかっていなかった自分でしたが、メモ化の仕組みをしっかり勉強することでなんとなくですが、理解度が高まった気がしています。

ただ、いつかこういったメモ化を~~色々考えるのはめんどくさいので~~考えなくて良い時代が来ると良いですね