👏

Reactで条件付きメモ化をするhooksを作ってみた

2024/08/04に公開

始めに

Reactでは useMemo でメモ化を行いますが、日付データやオブジェクトなど、プリミティブではないデータでは中身が同じでも再計算の対象になってしまいます。
例えば以下のようなuseMemoを呼ばれた数を記録しながら計算結果を表示するコンポーネントを作ったとします。

何も考慮せずuseMemoを使ったパターン
import { FC, useMemo, useRef } from 'react';
import { differenceInYears } from 'date-fns';

type SectionProps = {
  birthDate: Date;
  position: {
    x: number;
    y: number;
  };
};

const NormalMemoSection: FC<SectionProps> = ({ birthDate, position }) => {
  const numCalcAgeRef = useRef(0);
  const age = useMemo(() => {
    numCalcAgeRef.current += 1;
    return differenceInYears(new Date(), birthDate);
  }, [birthDate]);

  const numCalcDistanceRef = useRef(0);
  const distance = useMemo(() => {
    numCalcDistanceRef.current += 1;
    return Math.sqrt(position.x ** 2 + position.y ** 2);
  }, [position]);

  return (
    <div>
      <h2>通常のメモ化</h2>
      <div>
        年齢: {age}, (計算回数: {numCalcAgeRef.current})
      </div>
      <div>
        距離: {distance}, (計算回数: {numCalcDistanceRef.current})
      </div>
    </div>
  );
};

このコンポーネントの場合、渡し方を間違えるとデータの中身が同じであってもrerenderする度に再計算されてしまいます。

毎回再計算されてしまう
const App: FC = () => {
  const dateStr = '2000-01-01'
  const posX = 1
  const posY = 1

  return (
    <NormalMemoSection
      // メモ化せず毎回新しいDateインスタンスを作っている
      birthDate={new Date(dateStr)}
      // メモ化せず毎回新しいオブジェクトを作っている
      position={{
        x: posX,
        y: posY,
      }}
    />
  )
}

この状態だと例えば毎秒rerenderするようなコードだと、値自体は変わっていないのに毎秒再計算されてしまいます。

再計算されないようにするには以下のように書く必要があります。

毎回再計算されないようにメモ化してから渡す
 const App: FC = () => {
   const dateStr = '2000-01-01'
   const posX = 1
   const posY = 1

+  const birthDate = useMemo(() => new Date(dateStr), [dateStr])
+  const position = useMemo(() => {
+    return {
+      x: posX,
+      y: posY,
+    }
+  }, [posX, posY])

   return (
     <NormalMemoSection
-      // メモ化せず毎回新しいDateインスタンスを作っている
-      birthDate={new Date(dateStr)}
-      // メモ化せず毎回新しいオブジェクトを作っている
-      position={{
-        x: posX,
-        y: posY,
-      }}
+      birthDate={birthDate}
+      position={position}
     />
   )
 }

このように毎回気を付けていれば問題にはなりませんが、全て対応するのは漏れがあると思うので難しいと思っています。可能ならコンポーネント側でケアしたいところですよね。
Reactのメモ化はObject.isで判定していますが、この条件をdate-fnsのisSameDayやlodashのisEqualなど利用者側で指定できても良いのでは?と思ったので指定した条件でメモ化できるhooksを作ってみましたので記事にまとめました。

メソッドをメモ化する場合

この記事では日付とオブジェクトについて例を挙げましたが、メソッドについてもメモ化を考慮する必要があります。しかしこちらについては以下の記事に書かれておりますので、メソッドについてはこちらをご参照していただけると幸いです。

https://zenn.dev/numa_san/articles/ca5a811227ce79

条件によってメモ化するhooksの実装

条件によってメモ化するhooksは以下のようなコードになりました。

useConditionalMemo.ts
import { useRef } from 'react';

/**
 * 条件がtrueの時だけメモ化するhooks
 * @param value - メモ化対象のvalue
 * @param isSameHandler - 同じ値かを判定するハンドラ。trueの時にメモ化される
 */
export const useConditionalMemo = <T>(
  value: T,
  isSameHandler: (current: T, next: T) => boolean
): T => {
  /** 初回renderか */
  const isFirstRef = useRef(true);
  const currentValueRef = useRef<T>(value);

  // 初回renderの時は比較する必要がないので現在の値を返す
  if (isFirstRef.current) {
    isFirstRef.current = false;
    return currentValueRef.current;
  }

  // 現在の値と次の値を比較し、異なる値と判定されたら更新する
  if (!isSameHandler(currentValueRef.current, value)) {
    currentValueRef.current = value;
  }

  return currentValueRef.current;
};

useMemoと同じインターフェースにして第3引数に一致しているかの判定メソッドを用意することも考えましたが、以下の理由で辞めました。

  • depsとisSameHandlerとの型推論が難しくなる

  • useMemoでも懸念として上がるfactoryメソッドの内の変数が全てdepsに含まれていない可能性

2024/08/12追記: `useMemo`を拡張する検証

以下の記事でuseMemoとインターフェースを同じにして第3引数に判定ロジックを追加するパターンも試してみました!試した感じ、思っていたよりあっさり実装できたのでこっちの方が使い勝手が良いかもしれないなと思いました🤔

https://zenn.dev/numa_san/articles/ed834b8783ad60

これを以下のように呼び出すことで、Dateやオブジェクトが中身が同じ場合は同じインスタンスを維持してくれるため、再計算を抑制することができます。

useConditionalMemoを使って再計算を抑制する
 import { FC, useMemo, useRef } from 'react';
-import { differenceInYears } from 'date-fns';
+import { differenceInYears, isSameDay } from 'date-fns';
+import { isEqual } from 'lodash-es';

 const ConditionalMemoSection: FC<SectionProps> = ({ birthDate, position }) => {
   const numCalcAgeRef = useRef(0);
+  const memorizedBirthDate = useConditionalMemo(birthDate, (current, next) => {
+   return isSameDay(current, next)
+  })
   const age = useMemo(() => {
     numCalcAgeRef.current += 1;
-    return differenceInYears(new Date(), birthDate);
+    return differenceInYears(new Date(), memorizedBirthDate);
+  }, [memorizedBirthDate];
-  }, [birthDate]);

   const numCalcDistanceRef = useRef(0);
+  const memorizedPosition = useConditionalMemo(position, (current, next) => {
+    return isEqual(current, next)
+  })
   const distance = useMemo(() => {
     numCalcDistanceRef.current += 1;
-    return Math.sqrt(position.x ** 2 + position.y ** 2);
+    return Math.sqrt(memorizedPosition.x ** 2 + memorizedPosition.y ** 2);
+  }, [memorizedPosition]);
-  }, [position]);

   // renderの内容は同じなので省略
 };

一度条件付きメモ化処理を書いてからuseMemoする必要があるので少し手間がありますが、これで呼び出し側がメモ化を気にしなくても良くなりました😊
実際に動作を見ても各々で該当する値が変わった時だけ再計算されるようになっています。

今回は簡易的な実装なため useMemo とはインターフェースが異なってしまいましたが、より近い形で作るとメモ化処理を二度書かずに済みそうだなと思いました🤔

終わりに

以上がReactで条件付きメモ化するhooksを作ってみた話でした。処理が軽いものであれば正直メモ化は不要なのでそこまで神経質になる必要はありませんが、いざ重い処理が入った時にメモ化されていないとパフォーマンスに深刻な影響を受けてしまいますので、その時の対処法の参考になれれば幸いです。
最後に検証はStackBlitzで行っており、それを以下に貼りますので詳細の動きやコードを確認したい場合はご参照ください。

GitHubで編集を提案

Discussion