Rerenderを最小限に抑えるチェックリストを作る
始めに
Reactではstateを更新するたびにrerenderが走ってしまい、パフォーマンスに致命的な影響を与えてしまうことがあります。単純なチェックボックスであっても、以下のように全てのチェックボックスがrerenderされてしまい、数が膨大になるとパフォーマンスに影響を与えてしまいます。
rerenderの問題を最小限に抑える方法はないかと色々調べていたところ、以下の記事がとても参考になりました。
この仕組みを参考にチェックフラグが変わる時だけrerenderされるようにすると以下のように必要な箇所だけに抑えられました。
上の記事を参考にしましたが、いくつか違うところがありますので今回作った内容について備忘録としてまとめました。
サンプルコード
今回検証で書いたコードは以下に貼ります。動作やコードを確認したい方はこちらをご参照ください。
Rerenderを通知する仕組み
まず始めに、Rerenderされたことが視覚的に分からないと始まりません。詳細は以下の記事に書いていますが、こちらでもコードは載せておきます。
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で対応しています。
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);
}}
/>
<ContextCheckGroupList
groupItems={GROUP_ITEMS}
checkedItemIds={checkedItemIds}
onChangeCheckedItemIds={(newCheckedItemIds) => {
setCheckedItemIds(newCheckedItemIds);
}}
/>
したがってページコンポーネントは毎回rerenderされていますが、その状態であってもコンポーネントもmemo化しておくことで子コンポーネントが必要最低限の部分だけrerenderされるよう調整しています。
チェック状態を管理するContextを作る
まずはチェック状態を管理するロジックをContextに切り出します。Context経由でデータの取得ができるとrerender対象となるコンポーネントはuseContext
したものだけに抑えられます。更にProviderに流すvalueをしっかりmemo化しておくことでuseContext
を呼んでいるコンポーネントもrerenderされなくなります。
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経由で行えるようにします。
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されません。この問題は最初に紹介した記事にもあるオブザーバパターンで対応します。ただ今回はそこまで大袈裟なものは作らずに、単純に変更をコールバックで受け取れるように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
* @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を最小限に抑えたい時の参考になれれば幸いです。
Discussion