Reactで条件付きメモ化をするhooksを作ってみた
始めに
Reactでは 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を作ってみましたので記事にまとめました。
メソッドをメモ化する場合
この記事では日付とオブジェクトについて例を挙げましたが、メソッドについてもメモ化を考慮する必要があります。しかしこちらについては以下の記事に書かれておりますので、メソッドについてはこちらをご参照していただけると幸いです。
条件によってメモ化するhooksの実装
条件によってメモ化するhooksは以下のようなコードになりました。
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に含まれていない可能性-
react-hooks/exhaustive-deps
でチェックできるが、カスタムhooksで動作するか不明
一応設定はできそうだが、推奨はしていなさそう
https://github.com/facebook/react/blob/v18.3.1/packages/eslint-plugin-react-hooks/README.md#advanced-configurationWe suggest to use this option very sparingly, if at all. Generally saying, we recommend most custom Hooks to not use the dependencies argument, and instead provide a higher-level API that is more focused around a specific use case.
-
2024/08/12追記: `useMemo`を拡張する検証
以下の記事でuseMemo
とインターフェースを同じにして第3引数に判定ロジックを追加するパターンも試してみました!試した感じ、思っていたよりあっさり実装できたのでこっちの方が使い勝手が良いかもしれないなと思いました🤔
これを以下のように呼び出すことで、Dateやオブジェクトが中身が同じ場合は同じインスタンスを維持してくれるため、再計算を抑制することができます。
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で行っており、それを以下に貼りますので詳細の動きやコードを確認したい場合はご参照ください。
Discussion