useMemoを拡張してObject.is以外でメモ化条件を設定できる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;
};
シンプルな実装を目指したことで上のようなコードになりましたが、計算ロジックを渡せず対象の値をメモ化するかだけであるため、このhooksで一度メモ化してから更にuseMemo
を呼ぶ必要があるのが面倒だなと感じました。
// 同じ日の時、メモ化する
const memorizedBirthDate = useConditionalMemo(birthDate, (current, next) => {
return isSameDay(current, next);
})
const age = useMemo(() => {
numCalcAgeRef.current += 1;
return differenceInYears(new Date(), memorizedBirthDate);
// メモ化した日付をdepsに入れる
}, [memorizedBirthDate]);
こういう書き方になってしまうのであれば、useMemo
を拡張して第3引数にObject.is
以外で比較できるようになった方が使い勝手良いのかな?と思い、その実装も試してみました。
const age = useMemoEx(
() => {
numCalcAgeRef.current += 1;
return differenceInYears(new Date(), birthDate);
},
[birthDate],
// depsのパラメータを比較してメモ化するか判定する
([currentBirthDate], [nextBirthDate]) => {
return isSameDay(currentBirthDate, nextBirthDate)
}
);
useMemoを拡張してObject.is以外でメモ化条件を設定するhooksを作成
上の呼び出しイメージを元に、今回実装したhooksは以下のようになりました。factory
の実行結果を保存する際に、rerenderの度に実行されると本末転倒なのでuseState
の初期設定をメソッドにすることで最初の一回だけ実行されるようにしています。
import { useState, useRef } from "react";
type AcceptDepsPattern =
| Readonly<[]>
| Readonly<[any]>
| Readonly<[any, any]>
| Readonly<[any, any, any]>
| Readonly<[any, any, any, any]>
| Readonly<[any, any, any, any, any]>
| Readonly<any[]>;
// defaultIsMemoHandlerは次で記載
/**
* useMemoにメモ化条件でObject.is以外を指定できるようにしたhooks
* @param factory - メモ化対象の値を生成するメソッド
* @param deps - 依存パラメータ
* @param isMemo - 依存パラメータを比較してメモ化するか。trueの場合は更新しない
*/
export const useMemoEx = <T, Deps extends AcceptDepsPattern>(
factory: () => T,
deps: Deps,
isMemo: (current: Deps, next: Deps) => boolean = defaultIsMemoHandler
): T => {
/** 初回renderか */
const isFirstRef = useRef(true);
// 最初に実行した際の値を持つ
const [firstValue] = useState(() => {
return factory();
});
const currentValueRef = useRef<T>(firstValue);
const currentDeps = useRef<Deps>(deps);
// 初回renderの時は比較する必要がないので現在の値を返す
if (isFirstRef.current) {
isFirstRef.current = false;
return currentValueRef.current;
}
// メモ化したい場合のみ値を更新する
if (!isMemo(currentDeps.current, deps)) {
currentValueRef.current = factory();
}
currentDeps.current = deps;
return currentValueRef.current;
};
なお、デフォルト引数に入れているdefaultIsMemoHandler
は以下で、これがuseMemo
標準のObject.is
で比較するメモ化条件と同等なものになります。
/**
* Object.isでメモ化するかを判定するデフォルトのハンドラ
* @param current - 現在のdeps
* @param next - 次のdeps
*/
const defaultIsMemoHandler = (
current: Readonly<any[]>,
next: Readonly<any[]>
): boolean => {
if (current.length !== next.length) {
throw new Error("depsの数を変えることはできません");
}
for (let i = 0; i < current.length; i++) {
if (!Object.is(current[i], next[i])) {
return false;
}
}
return true;
};
ESLintの設定
最後にfactory
内で使用している変数がdeps
にキチンと含まれるようにreact-hooks/exhaustive-deps
を設定します。ドキュメントを見ると additionalHooks
でhooks名を指定するとカスタムhookでも見てくれるようなのでそれも設定します。
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config({
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
ignores: ['dist'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
+ 'react-hooks/exhaustive-deps': [
+ 'error',
+ {
+ additionalHooks: 'useMemoEx',
+ },
+ ],
},
});
StackBlitzだとエディタ上でESLintエラーは出てきませんが、npm run lint
することでキチンとエラーが出てくれました😊
終わりに
以上がuseMemoを拡張してObject.is以外でメモ化条件を設定するhooksの実装でした。最初はかなり実装が複雑になるかなと思いシンプルな仕様で対応しましたが、ESLintの設定も含めて意外とあっさりとできてしまったので、こちらの方が使い勝手良いかもなと思いました🤔 Object.is
以外の条件でメモ化したくなった時の参考になれれば幸いです。
検証コードは以下のStackBlitzで行いましたので、詳細のコードや動作が気になる方は是非ご参照ください。
Discussion