🪭

Rerenderを最小限に抑える汎用的な値監視Providerを作った

に公開

始めに

以前チェックリストの実装でrerenderを最小限に抑えるためにsubscribe形式でhooks内で値が変わった時のみ呼び出したコンポーネントだけrerenderされるような仕組みを作りました。

https://zenn.dev/numa_san/articles/0ead8cd9465a22

この方法を応用すればどのケースでもある程度適用できますが、毎回この仕組みを作るのは手間なのと可読性が少し落ちるのがネックでした。そこでより汎用的なものを作って、それベースで個別のユースケースにカスタマイズされるようなものを作りました。

汎用的な値監視Providerの実装

rerenderを最小限に抑えるためのコアの仕組みは、コンテキスト値は完全にメモ化して変わらないようにして、値の変更はリスナー経由のみで行うというものです。これに合った名前としてProviderはObserverProviderという名前にし、useSubscribeというhooksで値を取得するという方針にしました。Providerに渡すコンテキスト値は以下のようなものになります。

Observer Providerに渡すコンテキスト値の型
import type { SetStateAction, Dispatch } from 'react';

/**
 * 監視対象の値の変更を購読するリスナー
 * @param newValue - 監視対象値
 */
export type OnChangeObservedValueListener<T> = (newValue: T) => void;

/** 監視コンテキスト値 */
export type ObserverContextValue<T> = {
  /**
   * 監視対象の値を取得する
   */
  getObservedValue: () => T;
  /**
   * 監視対象の値を更新する
   */
  updateObservedValue: Dispatch<SetStateAction<T>>;
  /**
   * 監視対象の値の変更を購読する
   * @param listener - 監視対象の値の変更を購読するリスナー
   */
  addListener: (listener: OnChangeObservedValueListener<T>) => void;
  /**
   * 監視対象の値の変更の購読をやめる
   * @param listener - 監視対象の値の変更を購読しているリスナー
   */
  removeListener: (listener: OnChangeObservedValueListener<T>) => void;
};

定義したコンテキストに合うデータを作っていきますが、リスナーの管理部分はhooksで切り出しておくと見通しが良くなるので先にそれを実装します。

監視対象のリスナーを管理するhooks
const useObserverListeners = function <T>() {
  const listenersRef = useRef<OnChangeObservedValueListener<T>[]>([]);
  const addListener: ObserverContextValue<T>['addListener'] = useCallback(
    (listener) => {
      listenersRef.current.push(listener);
    },
    []
  );
  const removeListener: ObserverContextValue<T>['removeListener'] = useCallback(
    (listener) => {
      listenersRef.current = listenersRef.current.filter(
        (lis) => lis !== listener
      );
    },
    []
  );
  const broadcast = useCallback((value: T) => {
    listenersRef.current.forEach((listener) => {
      listener(value);
    });
  }, []);

  return {
    addListener,
    removeListener,
    /** 値を全リスナーに通知する */
    broadcast,
  };
};

このhooksも使ってProviderを実装すると以下のコードになります。汎用的なProviderなので何個でも作れるようにメソッドを通してProviderを返すようにしています。

ObserverProviderを作る関数
import type { SetStateAction, FC, PropsWithChildren, Context } from 'react';
import {
  useCallback,
  useRef,
  useEffect,
  useMemo,
} from 'react';

export type ObserverProviderProps<T> = PropsWithChildren<{
  /** 監視対象の値 */
  value: T;
  /**
   * 監視対象の値が変更したとき
   * @param newValue - 最新の監視対象の値
   */
  onChangeValue: (newValue: T) => void;
}>;

/**
 * updaterがメソッドかチェックする
 */
 function checkIsUpdaterFn<S>(
  updater: SetStateAction<S>
): updater is (prev: S) => S {
  return typeof updater === 'function';
}

export const createObserverProvider = function <T>(
  ObserverContext: Context<ObserverContextValue<T> | undefined>
) {
  const ObserverProvider: FC<ControlledObserverProviderProps<T>> = ({
    value,
    onChangeValue,
    children
  }) => {
    // deps対象から除外するためにrefに退避する
    const observedValueRef = useRef(value);
    observedValueRef.current = value;
    const onChangeValueRef = useRef(onChangeValue);
    onChangeValueRef.current = onChangeValue;

    const getObservedValue: ObserverContextValue<T>['getObservedValue'] =
      useCallback(() => {
        return observedValueRef.current;
      }, []);

    const updateObservedValue: ObserverContextValue<T>['updateObservedValue'] =
      useCallback((updater) => {
        const newValue = checkIsUpdaterFn(updater)
          ? updater(observedValueRef.current)
          : updater;
        onChangeValueRef.current(newValue);
      }, []);

    const { addListener, removeListener, broadcast } =
      useObserverListeners<T>();
    useEffect(() => {
      broadcast(value);
    }, [broadcast, value]);

    const contextValue = useMemo<ObserverContextValue<T>>(() => {
      return {
        getObservedValue,
        updateObservedValue,
        addListener,
        removeListener,
      };
    }, [getObservedValue, updateObservedValue, addListener, removeListener]);

    return (
      <ObserverContext.Provider value={contextValue}>
        {children}
      </ObserverContext.Provider>
    );
  }

  return ObserverProvider;
};

後はこれとセットでuseObserveなどのhooksも定義して一緒に返すようにしたら完成です。

汎用値監視のContextやProviderなど一式を返す
import { useState, createContext, useEffect, useContext } from 'react';
import type { ComponentType, FC } from 'react';

import type { ObserverContextValue } from './ObserverContextValue';
import { createObserverProvider } from './createObserverProvider';

/**
 * 対象の値を監視して変更時のみrerenderされるようにするContextやProviderなど一式を返す
 */
export const createObserverContextKit = function <T>({
  notProvidedErrorMessage = 'ObserverProviderがセットされていません',
}: {
  notProvidedErrorMessage?: string;
} = {}) {
  const ObserverContext = createContext<ObserverContextValue<T> | undefined>(
    undefined
  );

  const ObserverProvider = createObserverProvider(ObserverContext);

  const useObserverContextValue = () => {
    const contextValue = useContext(ObserverContext);
    if (contextValue == null) {
      throw new Error(notProvidedErrorMessage);
    }
    return contextValue;
  };

  const useObserve = function <U>(callback: (observedValue: T) => U) {
    const { getObservedValue, addListener, removeListener } =
      useObserverContextValue();

    const [result, setResult] = useState(() => {
      const observedValue = getObservedValue();
      return callback(observedValue);
    });

    useEffect(() => {
      const listener = (observedValue: T) => {
        const result = callback(observedValue);
        setResult(result);
      };

      addListener(listener);
      listener(getObservedValue());

      return () => {
        removeListener(listener);
      };
    }, [callback, addListener, removeListener]);

    return result;
  };

  return {
    ObserverProvider,
    withUncontrolledObserverProvider,
    useObserverContextValue,
    useObserve,
  };
};

使用例: チェック状態管理

createObserverContextKitを使って、冒頭で話したチェック状態の管理を実装してみます。汎用的な命名なので基本的には改めてユースケースに合ったProviderやhooksを定義してその中で呼び出します。特にupdateObservedValueは値を自由に変更できてしまうので用途を限定したhooksを返して内部だけ使うようにすることで影響範囲を制限できます。

createObserverContextKitを使ってチェック状態の管理Providerを実装する
import type { FC, PropsWithChildren } from 'react';
import { useCallback } from 'react';
import { uniq } from 'es-toolkit';

import { createObserverContextKit } from './createObserverContextKit';

const { ObserverProvider, useObserverContextValue, useObserve } =
  createObserverContextKit<number[]>({
    notProvidedErrorMessage: 'CheckManagementProviderがセットされていません',
  });

export type CheckManagementProviderProps = PropsWithChildren<{
  /** チェックした値リスト */
  checkValues: number[];
  /**
   * チェックした値リストが変更する時
   * @param newCheckValues - 新しいチェックした値リスト
   */
  onChangeCheckValues: (newCheckValues: number[]) => void;
}>;

/**
 * チェック状態を管理するProvider
 */
export const CheckManagementProvider: FC<CheckManagementProviderProps> = ({
  checkValues,
  onChangeCheckValues,
  children,
}) => {
  return (
    <ObserverProvider value={checkValues} onChangeValue={onChangeCheckValues}>
      {children}
    </ObserverProvider>
  );
};

/**
 * チェック値を変更するハンドラセットを返すhooks
 */
export const useChangeCheckValuesHandler = () => {
  const { updateObservedValue } = useObserverContextValue();

  const addCheckValues = useCallback(
    (values: number[]) => {
      updateObservedValue((currentValues) => {
        return uniq([...currentValues, ...values]);
      });
    },
    [updateObservedValue]
  );

  const removeCheckValues = useCallback(
    (values: number[]) => {
      updateObservedValue((currentValues) => {
        return currentValues.filter((val) => !values.includes(val));
      });
    },
    [updateObservedValue]
  );

  return {
    addCheckValues,
    removeCheckValues,
  };
};

/**
 * チェックリストに対応したチェックステータスを返すhooks
 * @param callback - 変更時に算出するチェックステータスを返すハンドラ
 */
export const useWatchCheckStatus = (
  callback: (newCheckValues: number[]) => boolean | 'indeterminate'
) => {
  const checkStatus = useObserve(callback);
  return checkStatus;
};

値は内部で管理して即時反映するパターンの追加

以上が基本的な実装ですが、一つだけ注意点があります。useObserverListenersbroadcastでリスナーに通知していますが、この実行タイミングがuseEffectで値変更を検知した時になるためワンサイクル遅れます。つまり一瞬だけ親で変わった値と子に反映された値にズレが発生します。一瞬なのでほとんどのケースでは気にしなくて良いと思いますが、例えば画面幅によってレイアウトを大きく変えるとかをするとチラつきが発生する可能性があります。
この問題を回避するには親で管理するのを辞めて内部で管理するようにします。内部で管理することで一度親に更新イベントを送る必要がなくなり、即時でリスナーに通知することができます。
内部で管理するパターンの変更はProvider部分だけ変えれば良いので、createObserverProviderを以下のように変えてUncontrolledパターンのProviderも実装して条件分岐して呼び出します。

Uncontrolledパターンにも対応する
 /** 監視する値を外から渡して管理するProvider */
 type ControlledObserverProviderProps<T> = PropsWithChildren<{
   /** 監視対象の値 */
   value: T;
   /**
    * 監視対象の値が変更したとき
    * @param newValue - 最新の監視対象の値
    */
   onChangeValue: (newValue: T) => void;
 }>;

+/** 監視する値を内部で管理するProvider */
+type UncontrolledObserverProviderProps<T> = PropsWithChildren<{
+  /** 監視対象の初期値 */
+  initialValue: T;
+}>

+export type ObserverProviderProps<T> = ControlledObserverProviderProps<T> | UncontrolledObserverProviderProps<T>

export const createObserverProvider = function <T>(
  ObserverContext: Context<ObserverContextValue<T> | undefined>
) {
   // ControlledObserverは既出なので省略

   // 監視対象の値を内部で管理するUncontrolledパターン
   //(Controlledとの差分が分かりやすいようにdiffで表示)
   const UncontrolledObserverProvider: FC<UncontrolledObserverProviderProps<T>> = ({ initialValue, children }) => {
     // deps対象から除外するためにrefに退避する
     const observedValueRef = useRef(initialValue);
-    const observedValueRef = useRef(value);
-    observedValueRef.current = value;
-    const onChangeValueRef = useRef(onChangeValue);
-    onChangeValueRef.current = onChangeValue;
    
     const getObservedValue: ObserverContextValue<T>['getObservedValue'] =
     useCallback(() => {
       return observedValueRef.current;
     }, []);
    
     const { addListener, removeListener, broadcast } =
       useObserverListeners<T>();
     const updateObservedValue: ObserverContextValue<T>['updateObservedValue'] =
       useCallback((updater) => {
         const newValue = checkIsUpdaterFn(updater)
           ? updater(observedValueRef.current)
           : updater;

-        onChangeValueRef.current(newValue);
+        // この時点で値を更新してリスナーに通知する
+        observedValueRef.current = newValue;
+        broadcast(newValue)
       }, [broadcast]);

     const contextValue = useMemo<ObserverContextValue<T>>(() => {
       return {
         getObservedValue,
         updateObservedValue,
         addListener,
         removeListener,
       };
     }, [getObservedValue, updateObservedValue, addListener, removeListener]);
 
     return (
       <ObserverContext.Provider value={contextValue}>
         {children}
       </ObserverContext.Provider>
     );
   }

+  // propsの中身を見てControlledかUncontrolledかを呼び出す
+  const ObserverProvider: FC<ObserverProviderProps<T>> = (props) => {
+    return 'value' in props ? <ControlledObserverProvider {...props} /> : <UncontrolledObserverProvider {...props} />
+  };

   return ObserverProvider;
 };

Uncontrolledパターンができましたが、値を変更するにはuseObserverContextValueからupdateObservedValueを取得する必要がありますが、Providerでラップされた中でしか使えず~Innerみたいなコンポーネントを用意して余計な内部コンポーネントが必要になる可能性があります。それを回避するためにwithUncontrolledObserverProviderというHOCをcreateObserverContextKitに含めます。

withUncontrolledObserverProviderを追加
 export const createObserverContextKit = function <T>({
   notProvidedErrorMessage = 'ObserverProviderがセットされていません',
 }: {
   notProvidedErrorMessage?: string;
 } = {}) {
   const ObserverContext = createContext<ObserverContextValue<T> | undefined>(
     undefined
   );
   const ObserverProvider = createObserverProvider(ObserverContext);

+  /**
+   * 監視する値を内部で管理するProviderをラップする
+   */
+  const withUncontrolledObserverProvider = function <P extends object>(
+    Component: ComponentType<P>,
+    {
+      initialValue: initialValueCreater,
+    }: {
+      initialValue: (props: P) => T;
+    }
+  ) {
+    const WrappedComponent: FC<P> = (props) => {
+      const [initialValue] = useState(() => {
+        return initialValueCreater(props);
+      });
+
+      return (
+        <ObserverProvider initialValue={initialValue}>
+          <Component {...props} />
+        </ObserverProvider>
+      );
+    };
+
+    return WrappedComponent;
+  };

   // 一部省略

   return {
     ObserverProvider,
     withUncontrolledObserverProvider,
     useObserverContextValue,
     useObserve,
   };
 };

これを用意しておくことでProviderをラップする時点でObserverContextValueにアクセスできるようになります。

withUncontrolledObserverProviderを使ってProviderを定義する例
export const MyUncontrolledProvider = withUncontrolledObserverProvider(
  ({ children }: PropsWithChildren<{ initialValue: any }>) => {
    // withUncontrolledObserverProviderでラップしているため、
    // ここでObserverContextValueにアクセスできる
    const { updateObservedValue } = useObserverContextValue();

    // updateObservedValueを使って更新できる

    return children;
  },
  {
    // propsの値を参照することもできる
    initialValue: ({ initialValue }) => initialValue,
  }
);

使用例: window幅の管理

こちらのUncontrolledを使った例として、window幅を管理するケースを実装しました。

createObserverContextKitを使ってUncontrolledなwindow幅の管理Providerを実装する
import type { PropsWithChildren } from 'react';
import { useEffect, useCallback } from 'react';

import { createObserverContextKit } from './createObserverContextKit';

const {
  withUncontrolledObserverProvider,
  useObserverContextValue,
  useObserve,
} = createObserverContextKit<number>({
  notProvidedErrorMessage: 'WindowWidthProviderがセットされていません',
});

export type WindowWidthProviderProps = PropsWithChildren;

/**
 * windowの幅を管理するProvider
 */
export const WindowWidthProvider = withUncontrolledObserverProvider(
  ({ children }: WindowWidthProviderProps) => {
    const { updateObservedValue } = useObserverContextValue();

    useEffect(() => {
      const handleResize = () => {
        updateObservedValue(window.innerWidth);
      };
      window.addEventListener('resize', handleResize);

      return () => {
        window.removeEventListener('resize', handleResize);
      };
    }, [updateObservedValue]);

    return children;
  },
  {
    initialValue: () => window.innerWidth,
  }
);

/**
 * windowの幅を取得
 */
export const useWindowWidth = () => {
  const callback = useCallback((width: number) => width, []);
  return useObserve(callback);
};

/**
 * 閾値以上の幅があるか
 * @param threshold - 閾値
 */
export const useIsLargerWidth = (threshold: number) => {
  const callback = useCallback(
    (width: number) => width >= threshold,
    [threshold]
  );
  return useObserve(callback);
};

useWindowWidthは毎回値が変わるのでrerenderしてしまいますが、useIsLargerWidthはthresholdを超えたか否かの時だけ変わるのでその時だけrerenderされることが確認できます。

終わりに

以上がRerenderを最小限に抑える汎用的な値監視Providerの実装方法でした。汎用的なProviderを作ったことでパフォーマンス改善のためにsubscribe形式に変えるのが非常にやりやすくなりました。rerenderを抑制したい方の参考になれば幸いです。
最後に検証で使ったコードは以下に貼っておきますので、詳細気になる方はこちらをご参照ください。

GitHubで編集を提案

Discussion