😊

Rerenderを最小限に抑えるチェックリストを作る

に公開

始めに

Reactではstateを更新するたびにrerenderが走ってしまい、パフォーマンスに致命的な影響を与えてしまうことがあります。単純なチェックボックスであっても、以下のように全てのチェックボックスがrerenderされてしまい、数が膨大になるとパフォーマンスに影響を与えてしまいます。

rerenderの問題を最小限に抑える方法はないかと色々調べていたところ、以下の記事がとても参考になりました。

https://zenn.dev/counterworks/articles/react-hook-form-subscription

この仕組みを参考にチェックフラグが変わる時だけrerenderされるようにすると以下のように必要な箇所だけに抑えられました。

上の記事を参考にしましたが、いくつか違うところがありますので今回作った内容について備忘録としてまとめました。

サンプルコード

今回検証で書いたコードは以下に貼ります。動作やコードを確認したい方はこちらをご参照ください。

Rerenderを通知する仕組み

まず始めに、Rerenderされたことが視覚的に分からないと始まりません。詳細は以下の記事に書いていますが、こちらでもコードは載せておきます。

https://zenn.dev/numa_san/articles/a90f20b92112aa#web-animations-apiで実装

NotifyRerender.tsx
import { FC, useRef, useEffect } from "react";

export const NotifyRerender: FC = () => {
  const elRootRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (elRootRef.current == null) {
      return;
    }

    elRootRef.current.animate([{ opacity: 1 }, { opacity: 0 }], {
      duration: 500,
      fill: "forwards"
    });
  });

  return (
    <div ref={elRootRef} style={{ color: "red" }}>
      Rerender
    </div>
  );
};

このコンポーネントを配置するだけでrerenderするたびにフェードアニメーションが表示されますが、チェックボックスの隣に毎回実装するのは大変なのでこのコンポーネントとセットにしたチェック用コンポーネントを作りました。ついでに今回はチェックとアンチェックの中間となるindeterminateの状態もあるので、それにも対応できるようにしています。
ちなみにindeterminateはDOMを直接操作するしか入れられなかったので以下の記事を参考にuseEffectで対応しています。

https://www.robinwieruch.de/react-checkbox-indeterminate/

InputCheck.tsx
import { FC, useRef, useEffect } from "react";

import { NotifyRerender } from "./NotifyRerender";

export type InputCheckProps = {
  isChecked: boolean;
  label: string;
  indeterminate?: boolean;
  onChangeIsChecked: (newIsChecked: boolean) => void;
};

export const InputCheck: FC<InputCheckProps> = ({
  isChecked,
  label,
  indeterminate,
  onChangeIsChecked
}) => {
  const elInputRef = useRef<HTMLInputElement | null>(null);

  useEffect(() => {
    if (elInputRef.current == null) {
      return;
    }

    elInputRef.current.indeterminate = indeterminate === true;
  }, [indeterminate]);

  return (
    <label
      style={{
        display: "inline-flex",
        padding: "5px",
        border: "solid 1px #ccc",
        borderRadius: "5px",
        cursor: "pointer"
      }}
    >
      <input
        ref={elInputRef}
        type="checkbox"
        checked={isChecked}
        readOnly
        onChange={() => {
          onChangeIsChecked(!isChecked);
        }}
      />
      <span style={{ marginRight: "4px" }}>{label}</span>
      <NotifyRerender />
    </label>
  );
};

これでこの部分ができました。

今回作ったチェックリストのデータ構成

今回のチェックのデータ構成は以下のようなグループ化されたもので作ります。

グループ項目
export type Item = {
  id: number;
};

export type GroupItem = {
  label: string;
  items: Item[];
};

export const GROUP_ITEMS: GroupItem[] = [
  {
    label: "グループ1",
    items: [{ id: 1 }, { id: 2 }, { id: 3 }]
  },
  {
    label: "グループ2",
    items: [{ id: 11 }, { id: 12 }, { id: 13 }]
  }
];

チェックされた項目はidリストだけstateで保持します。各項目がこのidリストに含まれていたらチェックを表示する感じになります。

const [checkedItemIds, setCheckedItemIds] = useState<number[]>([]);

Rerenderを最小限に抑える仕組みを作る

前提

今回のパフォーマンス調整ではインターフェースは通常の実装と全く同じ書き方ができる状態にするため、react-hook-formとは違い、チェックが変更する度に親には変更イベントが送られるようにしました。

通常のコンポーネント呼び出し
<NormalCheckList
  groupItems={GROUP_ITEMS}
  checkedItemIds={checkedItemIds}
  onChangeCheckedItemIds={(newCheckedItemIds) => {
    setCheckedItemIds(newCheckedItemIds);
  }}
/>
Contextを使ったパフォーマンス調整版のコンポーネント呼び出し
<ContextCheckGroupList
  groupItems={GROUP_ITEMS}
  checkedItemIds={checkedItemIds}
  onChangeCheckedItemIds={(newCheckedItemIds) => {
    setCheckedItemIds(newCheckedItemIds);
  }}
/>

したがってページコンポーネントは毎回rerenderされていますが、その状態であってもコンポーネントもmemo化しておくことで子コンポーネントが必要最低限の部分だけrerenderされるよう調整しています。

チェック状態を管理するContextを作る

まずはチェック状態を管理するロジックをContextに切り出します。Context経由でデータの取得ができるとrerender対象となるコンポーネントはuseContextしたものだけに抑えられます。更にProviderに流すvalueをしっかりmemo化しておくことでuseContextを呼んでいるコンポーネントもrerenderされなくなります。

チェック状態を管理するContextの土台を作る
import {
  FC,
  PropsWithChildren,
  createContext,
  useRef,
  useCallback,
  useContext,
  useMemo,
  useEffect
} from "react";
import { uniq } from "lodash-es";

type CheckManagementContextValue = {
  /**
   * チェックリストを取得する
   */
  getCheckValues: () => number[];
  /**
   * チェックリストを追加する
   * @param values - チェック対象の値リスト
   */
  addCheckValues: (values: number[]) => void;
  /**
   * チェックリストから取り除く
   * @param values - 除外チェック対象の値リスト
   */
  removeCheckValues: (values: number[]) => void;
};

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

const CheckManagementContext = createContext<
  CheckManagementContextValue | undefined
>(undefined);

export const CheckManagementProvider: FC<CheckManagementProviderProps> = ({
  checkValues,
  onChangeCheckValues,
  children
}) => {
  // deps対象から除外するためにrefに退避する
  const checkValuesRef = useRef(checkValues);
  const onChangeCheckValuesRef = useRef(onChangeCheckValues);

  const getCheckValues: CheckManagementContextValue["getCheckValues"] = useCallback(() => {
    return checkValuesRef.current;
  }, []);

  const addCheckValues: CheckManagementContextValue["addCheckValues"] = useCallback(
    (values) => {
      const currentCheckValues = checkValuesRef.current;
      onChangeCheckValuesRef.current(uniq([...currentCheckValues, ...values]));
    },
    []
  );

  const removeCheckValues: CheckManagementContextValue["removeCheckValues"] = useCallback(
    (values) => {
      const currentCheckValues = checkValuesRef.current;
      onChangeCheckValuesRef.current(
        currentCheckValues.filter((val) => !values.includes(val))
      );
    },
    []
  );

  useEffect(() => {
    checkValuesRef.current = checkValues;
  }, [checkValues]);

  useEffect(() => {
    onChangeCheckValuesRef.current = onChangeCheckValues;
  }, [onChangeCheckValues]);

  // useContextしたものが毎回rerenderされないようにメモ化したものをProviderに流す
  const contextValue = useMemo<CheckManagementContextValue>(
    () => ({
      getCheckValues,
      addCheckValues,
      removeCheckValues
    }),
    [
      getCheckValues,
      addCheckValues,
      removeCheckValues
    ]
  );

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

export const useCheckManagementContextValue = () => {
  const contextValue = useContext(CheckManagementContext);
  if (contextValue == null) {
    throw new Error("CheckManagementProviderがセットされていません。");
  }
  return contextValue;
};

これをコンポーネントのルートに配置し、これよりも配下のコンポーネントがどこでもContextの値を使えるようにし、かつ最新のチェックデータや更新する際のイベントはProvider経由で行えるようにします。

チェック状態を管理するContextを使用する
export type ContextCheckGroupListProps = {
  /** 選択した項目IDリスト */
  checkedItemIds: number[];
  /** 項目リスト */
  groupItems: GroupItem[];
  /**
   * 選択した項目リストを変える場合
   * @param newCheckedItemIds - 選択した項目IDリスト
   */
  onChangeCheckedItemIds: (newCheckedItemIds: number[]) => void;
};

export const ContextCheckGroupList: FC<ContextCheckGroupListProps> = ({
  checkedItemIds,
  groupItems,
  onChangeCheckedItemIds
}) => {
  return (
    <CheckManagementProvider
      checkValues={checkedItemIds}
      onChangeCheckValues={onChangeCheckedItemIds}
    >
      {/* 詳細の実装 */}
    </CheckManagementProvider>
  );
};

チェック状態を監視するhooksを作る

これで完全にrerenderを抑制したContextが作れましたが、このままだと値が変わってもrerenderされません。この問題は最初に紹介した記事にもあるオブザーバパターンで対応します。ただ今回はそこまで大袈裟なものは作らずに、単純に変更をコールバックで受け取れるようにaddListenerremoveListenerを用意して同等のものを作ります。

ContextにaddListenerとremoveListenerを提供する
+/**
+ * チェック状態の変更を購読するリスナー
+ * @param newCheckValues - チェックリスト
+ */
+type OnChangeCheckValuesListener = (newCheckValues: number[]) => void;

 type CheckManagementContextValue = {
   /**
    * チェックリストを取得する
    */
   getCheckValues: () => number[];
   /**
    * チェックリストを追加する
    * @param values - チェック対象の値リスト
    */
   addCheckValues: (values: number[]) => void;
   /**
    * チェックリストから取り除く
    * @param values - 除外チェック対象の値リスト
    */
   removeCheckValues: (values: number[]) => void;
+  /**
+   * チェック状態の変更を購読する
+   * @param listener - チェック状態の変更を購読するリスナー
+   */
+  addListener: (listener: OnChangeCheckValuesListener) => void;
+  /**
+   * チェック状態の変更の購読をやめる
+   * @param listener - チェック状態の変更を購読するリスナー
+   */
+  removeListener: (listener: OnChangeCheckValuesListener) => void;
 };

 export const CheckManagementProvider: FC<CheckManagementProviderProps> = ({
   checkValues,
   onChangeCheckValues,
   children
 }) => {
   // deps対象から除外するためにrefに退避する
   const checkValuesRef = useRef(checkValues);
   const onChangeCheckValuesRef = useRef(onChangeCheckValues);

+  const listenersRef = useRef<OnChangeCheckValuesListener[]>([]);

   // getCheckValue, addCheckValues, removeCheckValuesの定義は既出なので省略

+  const addListener: CheckManagementContextValue["addListener"] = useCallback(
+    (listener) => {
+      listenersRef.current.push(listener);
+    },
+    []
+  );

+  const removeListener: CheckManagementContextValue["removeListener"] = useCallback(
+    (listener) => {
+      listenersRef.current = listenersRef.current.filter(
+        (lis) => lis !== listener
+      );
+    },
+    []
+  );

   useEffect(() => {
     checkValuesRef.current = checkValues;

+    listenersRef.current.forEach((listener) => {
+      listener(checkValues);
+    });
   }, [checkValues]);

   useEffect(() => {
     onChangeCheckValuesRef.current = onChangeCheckValues;
   }, [onChangeCheckValues]);

   const contextValue = useMemo<CheckManagementContextValue>(
     () => ({
       getCheckValues,
       addCheckValues,
       removeCheckValues,
+      addListener,
+      removeListener
     }),
     [
       getCheckValues,
       addCheckValues,
       removeCheckValues,
+      addListener,
+      removeListener
     ]
   );

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

変更時にコールバックとして検知できるようになったため、これを使ってチェックステータスが変更できるhooksを用意します。グループチェックにも対応できるようにbooleanだけでなくindeterminateというリテラルも返せるようにしました。

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

  const [checkStatus, setCheckStatus] = useState(() => {
    const checkValues = getCheckValues();
    return callback(checkValues);
  });

  useEffect(() => {
    const listener = (newCheckValues: number[]) => {
      const newCheckStatus = callback(newCheckValues);
      setCheckStatus(newCheckStatus);
    };

    addListener(listener);

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

  return checkStatus;
};

listenerメソッド内でset関数を呼んでいるため、変更がある場合rerenderが走る仕組みになっています。ちなみに全く同じ値の場合はset関数を呼んでもrerenderはされないので、例えば既にtrueになっている状態で改めてtrueをsetしてもrerenderはされません。

Context経由でチェック状態を取得・更新する

あとはこれらを使って、単一のチェックとグループチェックのコンポーネントそれぞれを実装します。

単一のチェックコンポーネント
import { FC, memo } from "react";

export type ContextCheckForSingleProps = {
  /** 項目 */
  item: Item;
};

export const ContextCheckForSingle: FC<ContextCheckForSingleProps> = memo(
  ({ item }) => {
    const {
      addCheckValues,
      removeCheckValues
    } = useCheckManagementContextValue();
    const checkStatus = useWatchCheckStatus((checkValues) =>
      checkValues.includes(item.id)
    );
    return (
      <InputCheck
        label={JSON.stringify(item)}
        isChecked={checkStatus === true}
        onChangeIsChecked={(newIsChecked) => {
          if (newIsChecked) {
            addCheckValues([item.id]);
          } else {
            removeCheckValues([item.id]);
          }
        }}
      />
    );
  }
);
グループチェックコンポーネント
import { FC, memo } from "react";

export type ContextCheckForGroupProps = {
  /** グループ項目 */
  groupItem: GroupItem;
};

export const ContextCheckForGroup: FC<ContextCheckForGroupProps> = memo(
  ({ groupItem }) => {
    const allItemIds = groupItem.items.map((item) => item.id);

    const {
      addCheckValues,
      removeCheckValues
    } = useCheckManagementContextValue();
    const checkStatus = useWatchCheckStatus((checkValues) => {
      const isAllChecked = allItemIds.every((id) => checkValues.includes(id));
      const isAnyChecked = allItemIds.some((id) => checkValues.includes(id));

      return isAllChecked ? true : isAnyChecked ? "indeterminate" : false;
    });

    return (
      <InputCheck
        label="グループチェックトグル"
        isChecked={checkStatus === true}
        indeterminate={checkStatus === "indeterminate"}
        onChangeIsChecked={(newIsChecked) => {
          if (newIsChecked) {
            addCheckValues(allItemIds);
          } else {
            removeCheckValues(allItemIds);
          }
        }}
      />
    );
  }
);

終わりに

以上がrerenderを最小限に抑えるチェックリストを作る方法でした。実装はそれなりに難しかったですが、他でも応用できる設計方法だと思いますので、rerenderを最小限に抑えたい時の参考になれれば幸いです。

GitHubで編集を提案

Discussion