🙆

Reactにおける再レンダリングの事例と解決方法

2021/08/20に公開

はじめに

Reactでは、stateの設計や状態管理の実装がまずいと、たやすく過度な再レンダリングが発生します。
多少であれば無視できることも多いのですが、

  • 1つの大きなstateで画面を管理する
  • 要素の多い配列を元に画面を表示させる

といった場面では、UX上許容できないレベルまでパフォーマンスが低下しがちです。
そこで、過度な再レンダリングとその解消について、良い事例があったので記事にまとめました。

発生した事象


営業カレンダー設定画面で1つのトグルボタンを切り替えると、全トグルボタン(189個)が再レンダリングされていました。
もちろん、無視できないレベルのもっさり感です。
その時のソースコードの抜粋が下記となります。

containerコンポーネント内から抜粋

// 以上省略

const handleChangeOpen = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
        const target = daysData?.find((item) => item.date === e.target.dataset.index);
        const time = e.target.name.split('/')[1];

        dispatch(
            asyncDaysPutReserveCalendar({
                id,
                day: e.target.dataset.index,
                time,
                prevTimeTable: target.timetable,
                data: {
                    isOpen: target?.isOpen,
                    timetable: [{ time, isOpen: e.target.checked }],
                },
            }),
        );
    },
    [daysData, dispatch, id],
);

// 以下省略

ReserveDaysCalendarコンポーネント

export type ReserveDaysCalendarProps = {
    onPrevWeek: () => void;
    onNextWeek: () => void;
    isMin: boolean;
    isMax: boolean;
    year: string;
    month: string;
    days: {
        date: string;
        dateText: string;
        isOpen: boolean;
        timetable: TimeTable;
    }[];
    onChangeStatus: (e: React.ChangeEvent<HTMLSelectElement>) => void;
    onChangeOpen: (e: React.ChangeEvent<HTMLInputElement>) => void;
    permissions: { [key: string]: boolean };
};

export const ReserveDaysCalendar: FC<ReserveDaysCalendarProps> = ({
    onNextWeek,
    onPrevWeek,
    onChangeStatus,
    onChangeOpen,
    isMin,
    isMax,
    year,
    month,
    days,
    permissions,
}) => {
    const renderTimetable = useCallback(
        (date: string, timetable: TimeTable, isDisable: boolean) => (
            <ul>
                {timetable.map((item) => (
                    <li key={item.time} css={isDisable ? [style.table_item, style.table_disabled] : style.table_item}>
                        <ToggleBtn
                            value="1"
                            onChange={onChangeOpen}
                            name={`${date}/${item.time}`}
                            isChecked={item.isOpen}
                            isDisabled={isDisable}
                            index={date}
                            checkedLebal="受付可"
                            uncheckedLabel="受付不可"
                        />
                    </li>
                ))}
            </ul>
        ),
        [onChangeOpen],
    );

    // 以下省略

ToggleBtnコンポーネント

export type ToggleBtnProps = {
    value: string | number | undefined;
    name: string;
    isChecked: boolean;
    isDisabled?: boolean;
    onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
    checkedLebal?: string;
    uncheckedLabel?: string;
    index?: number | string;
};

export const ToggleBtn: FC<ToggleBtnProps> = React.memo(
    ({ value, name, isChecked, isDisabled, onChange, checkedLebal, uncheckedLabel, index }) => {
        console.log('toggle');

        return (
            <>
                <input
                    css={style.input}
                    type="checkbox"
                    id={name}
                    name={name}
                    value={value}
                    checked={isChecked}
                    onChange={onChange}
                    disabled={isDisabled}
                    data-index={index}
                />
                <label css={style.label} htmlFor={name} data-index={index} id={name} />
                {(checkedLebal || uncheckedLabel) && <span css={style.text}>{isChecked ? checkedLebal : uncheckedLabel}</span>}
            </>
        ),

    }
);

ReserveDaysCalendarの中でrenderTimeTable関数を作成し、その中でToggleBtnコンポーネントを呼び出しています。
ここで注目して欲しいのは、ToggleBtnコンポーネントがReact.memo()によってメモ化されているということです。

細かい説明は省略しますが、メモ化されたコンポーネントは、受け取るpropsが変更されない限り再レンダリングされません。
つまり、今回の再レンダリングの原因は、ToggleBtnコンポーネントが受けとっているpropsのいずれかだということがわかります。
犯人の目星が付いたところで、さらに原因を探っていきましょう。

何が再レンダリングを引き起こしているのか

ToggleBtnコンポーネントが受け取るpropsを見てみましょう。各種テキストやboolean等は、他のToggleBtnの操作によって更新されることは無さそうですね。
このような場合に真っ先に疑うべきは、onChange関数です。
今回の場合、関数はcontainerコンポーネントで実装してpropsとして渡しています。
一見するとその中身が更新されることは無さそうですが、問題は、関数が参照渡しだという点です。

stateの更新等によって親コンポーネントが再レンダリングされると、当然そのコンポーネント内で記述している関数も再生成されます。
その際に参照先に差異が生じ、全く同じ中身であるにも関わらず、異なる関数であると判断されてしまいます。

関数の再生成を防ぐ

そのような関数の再生成を防ぐ = 関数をメモ化するために、ReactではuseCallback()を用います。
ところが、今回の例を見てみると、すでにhandleChangeOpen関数をuseCallbackでメモ化済みですね。

ここで注目して欲しいのは、useCallback()の第二引数です。
useCallback()を用いる時は、第一引数にメモ化したい関数を、第二引数にその関数が依存する値を含めた配列を与えます。

関数が何らかの外部の値に依存していた場合、それが変更された際にはメモ化された内容も更新する必要があります。
それを制御しているのが、第二引数に与えた依存配列です。
ここでは、「この配列に含めた値が更新された際に、メモ化された関数を再生成してください」という指示を与えているわけです。

今回の例だと、関数自体がdaysData(1週間分のタイムテーブルや定休日を含めた配列)に依存する設計になっています。
それにより、

  • 何らかのToggleBtnでdaysDataを更新する
  • daysDataが更新されたことで、関数が再生成される
  • その関数をpropsとして受け取っているToggleBtnコンポーネントも、全て再レンダリングされる

という事態に陥っています。
これだと、React.memo()もuseCallback()も意味がありません。

再レンダリングの解消

この場合、再レンダリングを解消する方法として、

  • daysDataを細かく分解する
  • handleChangeOpen関数がdaysDataに依存しないようにする

の2つの方法が考えられます。

今回は、影響範囲が少なそうな

  • handleChangeOpen関数がdaysDataに依存しないようにする

方向で修正を加えたいと思います。

関数の修正

まずはシンプルに、依存している値を引数として渡すように修正してみます。
関数の中身を見てみると、

  • isOpenとtimetableを引数で渡す
  • クリック時のイベントを引数で渡す

この2点を両立させる関数が必要になりそうです。

このような場合に役立つのが、関数を返す関数、つまり高階関数です。
具体的には、
引数:isOpenとtimetable
返り値:クリック時のイベントを引数にとる関数
となります。ソースコードは下記の通りです。

const handleChangeOpen = useCallback(
    (isOpen:boolean, timetable:TimeTable) => (e: React.ChangeEvent<HTMLInputElement>) => {
        const time = e.target.name.split('/')[1];
	
        dispatch(
            asyncDaysPutReserveCalendar({
                id,
                day: e.target.dataset.index,
                time,
                prevTimeTable: timetable,
                data: {
                    isOpen: isOpen,
                    timetable: [{ time, isOpen: e.target.checked }],
                },
            }),
        );
    },
    [dispatch, id],
);

renderTimeTableの修正

次に、renderTimeTable関数を修正します。
まずは、renderTimetable関数自体をコンポーネントに変更した上で、React.meme()によってメモ化します。
その後、新たに作成したコンポーネント内でhandleChangeOpen関数に1段階目の引数を与えましょう。

export type ReserveDaysCalendarProps = {
    onPrevWeek: () => void;
    onNextWeek: () => void;
    isMin: boolean;
    isMax: boolean;
    year: string;
    month: string;
    days: {
        date: string;
        dateText: string;
        isOpen: boolean;
        defaultIsOpen: boolean;
        timetable: TimeTable;
    }[];
    onChangeStatus: (isOpen: boolean) => (e: React.ChangeEvent<HTMLSelectElement>) => void;
    onChangeOpen: (isOpen: boolean, timetable:TimeTable) => (e: React.ChangeEvent<HTMLInputElement>) => void;
    permissions: { [key: string]: boolean };
};

type TimeTableProps = {
    date: string;
    timetable: TimeTable;
    isDisable: boolean;
    isOpenDay: boolean;
    onChange: ReserveDaysCalendarProps['onChangeOpen'];
};
const TimeTable: FC<TimeTableProps> = React.memo(({ date, isOpenDay, timetable, isDisable, onChange }) => {
    const onToggle = useMemo(() => onChange(timetable, isOpenDay), [timetable, isOpenDay, onChange]);

    return (
        <ul>
            {timetable.map((item) => (
                <li key={item.time} css={isDisable ? [style.table_item, style.table_disabled] : style.table_item}>
                    <ToggleBtn
                        value="1"
                        onChange={onToggle}
                        name={`${date}/${item.time}`}
                        isChecked={item.isOpen}
                        isDisabled={isDisable}
                        index={date}
                        checkedLebal="受付可"
                        uncheckedLabel="受付不可"
                    />
                </li>
            ))}
        </ul>
    );
});

export const ReserveDaysCalendar: FC<ReserveDaysCalendarProps> = ({
// 以下省略

ここまでの結果


以上の修正により、ToggleBtnクリック時の再レンダリングを、1日分(27回)まで抑えることができました。
なぜ1日分なのかというと、TimeTableコンポーネント内のonToggle関数がtimetable(1日分のタイムテーブルを収めた配列)に依存しているからです。

  • いずれかのToggleBtnをクリックし、timetableを更新
  • そのtimetableに依存している、同日のToggleBtnが再レンダリングされる

という、まるでこれまでの縮小版のような流れによって再レンダリングが引き起こされています。
これ以上のパフォーマンス改善を望むならば、handleChangeOpen関数内で行われているdispatchの設計自体を変更する必要がありそうです。

さらなる再レンダリング改善

幸い、今回はAPIからの返り値を利用することによってそれが実現できました。
完成版のソースコードは下記の通りです。
(ToggleBtnコンポーネントについては、変更点が無いため省略しています)

containerコンポーネント内から抜粋

// 以上省略

const handleChangeOpen = useCallback(
    (isOpen: boolean) => (e: React.ChangeEvent<HTMLInputElement>) => {
        const time = e.target.name.split('/')[1];

        dispatch(
            asyncDaysPutReserveCalendar({
                id,
                day: e.target.dataset.index,
                data: {
                    isOpen,
                    timetable: [{ time, isOpen: e.target.checked }],
                },
             }),
        );
    },
    [dispatch, id],
);

// 以下省略

ReserveDaysCalendarコンポーネント

export type ReserveDaysCalendarProps = {
    onPrevWeek: () => void;
    onNextWeek: () => void;
    isMin: boolean;
    isMax: boolean;
    year: string;
    month: string;
    days: {
        date: string;
        dateText: string;
        isOpen: boolean;
        defaultIsOpen: boolean;
        timetable: TimeTable;
    }[];
    onChangeStatus: (isOpen: boolean) => (e: React.ChangeEvent<HTMLSelectElement>) => void;
    onChangeOpen: (isOpen: boolean) => (e: React.ChangeEvent<HTMLInputElement>) => void;
    permissions: { [key: string]: boolean };
};

type TimeTableProps = {
    date: string;
    timetable: TimeTable;
    isDisable: boolean;
    isOpenDay: boolean;
    onChange: ReserveDaysCalendarProps['onChangeOpen'];
};
const TimeTable: FC<TimeTableProps> = React.memo(({ date, isOpenDay, timetable, isDisable, onChange }) => {
    const onToggle = useMemo(() => onChange(isOpenDay), [isOpenDay, onChange]);

    return (
        <ul>
            {timetable.map((item) => (
                <li key={item.time} css={isDisable ? [style.table_item, style.table_disabled] : style.table_item}>
                    <ToggleBtn
                        value="1"
                        onChange={onToggle}
                        name={`${date}/${item.time}`}
                        isChecked={item.isOpen}
                        isDisabled={isDisable}
                        index={date}
                        checkedLebal="受付可"
                        uncheckedLabel="受付不可"
                    />
                </li>
            ))}
        </ul>
    );
});

export const ReserveDaysCalendar: FC<ReserveDaysCalendarProps> = ({
    // 以下省略


この修正により、不要な再レンダリング回数を0回まで減らすことができました。
ここまでくればクリック時の動作も文句なしです。

まとめ

Reactで考えなしに実装を進めていくと、このように「動作はするけど非常に重い...」という状況に陥る恐れがあります(自戒)。
その際に原因となりがちなのが、巨大なstateや配列と、状態管理用の関数です。
場合によっては、各処理の見直しや設計変更が必要になることもあります。
しかし、原因を特定しさえすれば解決も難しくありません。
Reactの特性を把握し、より良い実装を目指しましょう。

Discussion