💭

useMemoを拡張してObject.is以外でメモ化条件を設定できるhooksを作ってみた

2024/08/12に公開

始めに

以前以下の記事で条件によってメモ化するhooksを作りました。

https://zenn.dev/numa_san/articles/5dec13c6b4d0a8

条件によってメモ化する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を呼ぶ必要があるのが面倒だなと感じました。

一度条件付きメモ化をしてからuseMemoのdepsに渡す手間
// 同じ日の時、メモ化する
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以外で比較できるようになった方が使い勝手良いのかな?と思い、その実装も試してみました。

useMemoを拡張してメモ化条件を設定できるようにするコードのイメージ
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で比較するメモ化条件と同等なものになります。

defaultIsMemoHandler
/**
 * 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でも見てくれるようなのでそれも設定します。

https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/README.md#advanced-configuration

eslint.config.js
 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で行いましたので、詳細のコードや動作が気になる方は是非ご参照ください。

GitHubで編集を提案

Discussion