React.memoを使ったレンダリング最適化入門

9 min read読了の目安(約8400字

はじめに

React のメモ化をなんとなく使う現状から卒業したかったので、下記リンクを参考に[1]React.memo useCallback useMemoをそれぞれ実装してみたまとめ

https://qiita.com/soarflat/items/b9d3d17b8ab1f5dbfed2

記事のターゲット

  • React.memo useCallback useMemoをどこか遠ざけていた人
  • 親や子コンポーネントのレンダリングタイミングが気になる人
  • レンダリングの最適化がかっこいい響きに見える人
  • React 書き始めた人にそのコンポーネントちゃんと最適化してる?とかドヤりたい人

環境

関係ありそうなとこだけ

package.json
  "dependencies": {
    "next": "10.0.3",
    "react": "17.0.1",
    "react-dom": "17.0.1",
  },

実際に挙動を確認するために実装したコードはここに

https://github.com/yota-hada/p-next/tree/main/src/pages/memo

どのタイミングでコンポーネントはレンダリングされるのか

自分の state が更新されたときに自分自身+使っている子コンポーネントが再レンダリングされる

type ChildProps = {
  count: number;
};

const Child = ({ count }: ChildProps): JSX.Element => {
  useEffect(() => {
    console.log("Childがレンダリングされたよ");
  });

  return <p>Child:{count}</p>;
};

const Parent: NextPage = () => {
  const [parentCount, setParentCount] = useState<number>(0);
  const [childCount, setChildCount] = useState<number>(0);

  useEffect(() => {
    console.log("Parentがレンダリングされたよ");
  });

  return (
    <div>
      <button
        type="button"
        onClick={() => {
          setParentCount(parentCount + 1);
        }}
      >
        Parent count up
      </button>
      <button
        type="button"
        onClick={() => {
          setChildCount1(childCount1 + 1);
        }}
      >
        Child1 count up
      </button>
      <p>Parent:{parentCount}</p>
      <Child count={childCount} />
    </div>
  );
};

この場合setParentCountsetChildCountが呼ばれ state の値が更新されるたびにChildParentも再レンダリングされる

これぐらいの要素数なら特段気にする必要もないが、もしこの Child コンポーネントがレンダリングされるたびに重い計算処理をしていたり、Child コンポーネントのなかに大量に表示される要素がある場合、レンダリングコストが高くなる
Child コンポーネントをメモ化しておけば Child が描画するのに関係ない値が親コンポーネント側で変更されてもレンダリングをスキップすることができる

メモ化してみる

React.memoを使うと親から子コンポーネントに渡している props が更新されない限り(後述する callback 関数とかは別)子コンポーネントは再レンダリングされない

- import { useEffect, useState } from 'react'
+ import { memo, useEffect, useState } from 'react'

+ const ChildMemo = memo<ChildProps>(({ count }) => <Child count={count} />)

  return (
    <div>
 -      <Child count={childCount} />
+      <ChildMemo count={childCount} />
    </div>
  )

この場合setParentCountが呼ばれてもメモ化されたChildは再レンダリングされずにParentだけ再レンダリングされる(Child に渡している props の値は更新されていないので)
setChildCountChildの props に渡している値を更新するとChildParentも再レンダリングされる

useMemo と React.memo の違い

React.memo の hook 版じゃねと思ったら違ったって話。

さっきのコードを useMemo で書き換えてみる

- import { memo, useEffect, useState } from 'react'
+ import { useEffect, useMemo, useState } from 'react'

- const ChildMemo = memo<ChildProps>(({ count }) => <Child count={count} />)

+ const childMemo = useMemo(() => <Child count={childCount} />, [])

  return (
    <div>
 -      <ChildMemo count={childCount} />
+      {childMemo}
    </div>
  )

useMemoは第二引数に依存配列を指定する
依存配列で指定した値が更新された時に再レンダリングされる
今回の書き方だと依存配列に何も指定していないのでChildはレンダリングされない

依存配列を指定することでchildCountが更新されたらChildが再レンダリングされる様になる

- const childMemo = useMemo(() => <Child count={childCount} />, [])
+ const childMemo = useMemo(() => <Child count={childCount} />, [childCount])

React.memo 化された子コンポーネントの props に callback 関数を渡したときの挙動

親で定義した関数を子コンポーネントに props として渡すと子コンポーネントを memo 化しても再レンダリングされる

Child コンポーネント

type ChildProps = {
  count: number
+  handleClick: () => void
}

- return <p>Child:{count}</p>;

+  return (
+    <>
+      <p>Child:{count}</p>
+      <button
+        type="button"
+        onClick={() => {
+          handleClick()
+        }}
+      >
+        Child Button
+      </button>
+    </>
+  )

Parent コンポーネント

+  const handleClick = () => {
+    console.log('Childのボタンが押されたよ')
+  }

  return (
    <div>
     {/* ....省略 */}
 -      <ChildMemo count={childCount} />
+      <ChildMemo count={childCount} handleClick={handleClick} />
    </div>
  )

親がレンダリングされるたびに親で定義した関数も再生成されるので結果として、子コンポーネントを memo 化しても親の state が更新されると子コンポーネントも再レンダリングされてしまう

useCallback

親がレンダリングされるたびに親で定義した関数も再生成されないようにuseCallbackで関数をメモ化する
useCallback は useMemo の糖衣構文っぽい

useCallback(fn, deps) は useMemo(() => fn, deps) と等価です。
https://ja.reactjs.org/docs/hooks-reference.html#usecallback

- import { memo, useCallback, useEffect, useState } from 'react'
+ import { memo, useEffect, useState } from 'react'

-  const handleClick = () => {
-    console.log('Childのボタンが押されたよ')
-  }

+  const handleClick = useCallback(() => {
+    console.log('Childのボタンが押されたよ')
+  }, [])

useCallbackuseMemoと同様(useMemo の糖衣構文っぽいしそりゃそうか)、第二引数に依存配列を受け取り、依存配列で指定した値が更新されると useCallback でメモ化した関数が再計算される
結果として、親の state が更新されても memo 化された子コンポーネントは再レンダリングされない

useCallback する関数に引数がある場合どうすればいいの?

単に useCallback で渡す関数を引数付きにしてあげれば良い

Child コンポーネント

type ChildProps = {
  count: number
-  handleClick: () => void
+  handleClick: (text: string) => void
}

      <button
        type="button"
        onClick={() => {
-          handleClick()
+          handleClick('Childのボタンが押されたよ')
        }}
      >

Parent

-  const handleClick = useCallback(() => {
+  const handleClick = useCallback((text: string) => {
-    console.log('Childのボタンが押されたよ')
+    console.log(text)
  }, [])

userReducer と memo 化を組み合わせたときの挙動

reducer の実装って immutable な気がしていて、state がオブジェクトの場合、一部のプロパティを更新しただけでも他のコンポーネントも再レンダリングされるんじゃ・・と思ってたらそうでなかったってお話

reducer 周り

export type State = {
  count1: number;
  count2: number;
  count3: number;
  count4: number;
};

export const updateCount1 = (
  state: State,
  { value }: UpdateCount1Payload
): State => ({ ...state, count1: value });
// ... updateCount2, updateCount3, updateCount4が続く

export enum ActionType {
  UpdateCount1 = "UpdateCount1",
  // ...
}

export type Action =
  | {
      type: ActionType.UpdateCount1;
      payload: UpdateCount1Payload;
    }
  | {
      type: ActionType.UpdateCount2;
      payload: UpdateCount2Payload;
    };
// ...

export const reducer: Reducer<State, Action> = (state, action) => {
  switch (action.type) {
    case ActionType.UpdateCount1:
      return updateCount1(state, action.payload);
    // ...
    default:
      throw new TypeError(`unexpected action. ${action}`);
  }
};

子コンポーネント

const Child = ({ count, label }: ChildProps): JSX.Element => {
  useEffect(() => {
    console.log(`Child${label}がレンダリングされたよ`);
  });

  return (
    <p>
      Child{label}{count}
    </p>
  );
};

const ChildMemo = memo<ChildProps>(({ count, label }) => {
  return <Child count={count} label={label} />;
});

親コンポーネント

import { memo, useCallback, useEffect, useReducer, useState } from "react";
import { ActionType } from "state/action";
import { reducer } from "state/reducer";
import { initialState } from "state";

const Parent: NextPage = () => {
  const [parentCount, setParentCount] = useState<number>(0);
  const [childState, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    console.log("Parentがレンダリングされたよ");
  });

  return (
    <div>
      <button
        type="button"
        onClick={() => {
          setParentCount(parentCount + 1);
        }}
      >
        Parent count up
      </button>
      <button
        type="button"
        onClick={() => {
          dispatch({
            type: ActionType.UpdateCount1,
            payload: {
              value: childState.count1 + 1,
            },
          });
        }}
      >
        Child1 count up
      </button>
      {/* count2, count3...の値をカウントアップするようのボタンが同様にある */}
      <p>Parent:{parentCount}</p>
      <ChildMemo count={childState.count1} label="01" />
      {/* count2, count3...の値をcountのpropに渡すChildMemoコンポーネントがある */}
    </div>
  );
};

Childコンポーネントをメモ化したChildMemoは reducer で定義された state のプロパティを props として受け取っているので、setParentCountが呼ばれて親の state が更新されてもChildコンポーネントは再レンダリングされない
reducer で定義された state のプロパティを更新した場合は、そのプロパティを props として渡しているChildコンポーネントとParentコンポーネントのみ再レンダリングされる

まとめ

敬遠してたメモ化だけども、手を動かして実装してみると意外とイメージできたからヨキでした

脚注
  1. 記載されているコードから気になる部分を深ぼって実装したので似た様なコードがあります。とてもわかりやすい記事だったのでそちらもご参考に。 ↩︎