🐩

【React】メモ化する

2023/07/21に公開

はじめに

ページ内に「A」と「B」の2つの入力項目があります。
「A」と「B」の値に依存関係はなく、親コンポーネントで定義した1つのstateで管理しています。
このとき、「A」の値を変更すると、「B」の再レンダリングが行われます。
「B」は何も変更していないのに。

開発環境

Windows 10
TypeScript 4.9.5
React 18.2.0
Visual Studio 2022

サンプルコード

冒頭のページは以下のようなコードになっていることとします。

parent.tsx
import { useState } from 'react';

type Values = {
    valueA: string;
    valueB: number;
}

const Parent = () => {
    const [values, setValues] = useState<Values>({
        valueA: '',
        valueB: 0
    });

    const setValueA = (value: string) => {
        setValues({ ...values, valueA: value });
    }

    const setValueB = (value: number) => {
        setValues({ ...values, valueB: value });
    }

    return (
        <div>
            <ChildA value={values.valueA} setValue={setValueA} />
            <ChildB value={values.valueB} setValue={setValueB} />
        </div>
    );
}
childA.tsx
type PropsA = {
    value: string;
    setValue: (value: string) => void;
}

const ChildA = (props: PropsA) => {
    console.log('ChildA');
    return (
        <input
            type='text'
            value={props.value}
            onChange={event => props.setValue(event.target.value)}
        />
    );
}
childB.tsx
type PropsB = {
    value: number;
    setValue: (value: number) => void;
}

const ChildB = (props: PropsB) => {
    console.log('ChildB');
    return (
        <input
            type='number'
            value={props.value}
            onChange={event => props.setValue(event.target.value)}
        />
    );
}

親コンポーネントで定義したvaluesというstateで「A」「B」それぞれの値valueAvalueBを管理しており、子コンポーネントのpropsのvalueに渡しています。
また、子コンポーネントには値を変更するための関数をsetValueとして渡しており、各子コンポーネント内のinputonChangeでこれを実行しています。
加えて、レンダリングが行われたことがわかるよう、各子コンポーネントの先頭にconsole.logを書いています。

上記のサンプルコードでは、「A」のテキストボックスに文字を入力するたびに、

> childA
> childB

の2行セットが毎回コンソールに出力されます。
要は、1文字入力するたびにすべての子コンポーネントがレンダリングされている訳です。

このサンプルでは入力項目が2つしかなく、体感では何も気になりません。
しかし、同じ構成で子コンポーネントを数百個配置した場合、1文字入力するたびに画面が固まったようになります。

メモ化

できれば、入力していない項目のレンダリングは控えたいです。
それを実現するために、reactでは「メモ化」という機能があります。
「メモ化」とは、入力情報が変わらない場合に前回処理した結果をそのまま使う機能です。

React.memo

さっそくサンプルコードをメモ化してみます。
まずは子コンポーネントChildAから。

childA.tsx
+ import * as React from 'react';

type PropsA = {
    value: string;
    setValue: (value: string) => void;
}

+ const ChildA = React.memo<PropsA>((props: PropsA) => {
- const ChildA = (props: PropsA) => {
    console.log('ChildA');
    return (
        <input
            type='text'
            value={props.value}
            onChange={event => props.setValue(event.target.value)}
        />
    );
+ });
- }

ChildA全体をReact.memoで囲みました。
これによって、「propsが同じなら、前回レンダリングしたChildAの結果をそのまま使う」ことができます。
ChildBにも同様の変更を行うことで、「A」に文字入力したとき、

> childA

だけがコンソール出力され、「B」のレンダリングが行われなくなる・・・
はずですが、これだけではまだ

> childA
> childB

がコンソール出力されてしまいます。

原因は

childA.tsx
onChange={event => props.setValue(event.target.value)}

props.setValueに毎回"異なる"関数が渡されるため、「propsが異なる」と判断されないためです。

use.Callback

props.setValueも毎回"同じ"関数を渡すため、親コンポーネントでuseCallbackを宣言します。
useCallbackは関数を丸ごとメモ化するイメージで、第2引数の配列に指定されたいずれかのパラメータに変更がなければ関数の再評価を行わない(=前回と"同じ"関数を返す)という、reactの標準フックです。

parent.tsx
+ import { useState, useCallback } from 'react';
- import { useState } from 'react';

type Values = {
    valueA: string;
    valueB: number;
}

const Parent = () => {
    const [values, setValues] = useState<Values>({
        valueA: '',
        valueB: 0
    });

+    const setValueA = useCallback((value: string) => {
-    const setValueA = (value: string) => {
        setValues({ ...values, valueA: value });
+    }, [values]);
-    }

+    const setValueB = useCallback((value: number) => {
-    const setValueB = (value: number) => {
        setValues({ ...values, valueB: value });
+    }, [values]);
-    }

    return (
        <div>
            <ChildA value={values.valueA} setValue={setValueA} />
            <ChildB value={values.valueB} setValue={setValueB} />
        </div>
    );
}

これで無事に、「A」に文字入力したとき

> childA

だけがコンソール出力されるようになります。

propsが"同じ"とは?

今回の場合、子コンポーネントは親から与えられたpropsが"同じ"ときReact.memoの機能によって再レンダリングされなくなりました。
では、propsが"同じ"とはどういう状況でしょうか。

ここまでの例で、ChildAは親からvalueというstring型の値とsetValueという関数を受け取っています。
setValueについてはuseCallbackで"同じ"関数が渡されることになったので良いですが、valueは何をもって"同じ"と判定されるのか、が問題です。

実は、内部の判定ではObject.isがTrueのときに"同じ"と判断されるようです。
プリミティブ型であれば、同値であれば"同じ"になります。
オブジェクト型であれば「浅い比較」がTrueであれば"同じ"になります。

今回のvalueはプリミティブ型なのであまり考えなくて良いですが、これがオブジェクト型だった場合、見た目は同じでも"異なる"と判断され、毎回レンダリングが発生することになります。(私はここではまりました)

オブジェクト型を「深い比較」で比較したい場合は、独自の比較関数を定義する必要があります。
https://react.dev/reference/react/memo
上記サイトに説明があるarePropsEqualに、"同じ"ならTrueを返す独自の比較関数を割り当てることで対応できるようです。

Propsに関数を指定するときの注意

上記のサンプルコードで

parent.tsx
setValue={setValueA}

の箇所は「setValueに関数setValueA自体を指定している」という意味になります。
もしこれが

parent.tsx
setValue={setValueA()}

とか書いてしまうと「setValueに関数setValueAの結果を指定している」という意味になり、コンパイルエラーとなります。

メモ化 不要論

ちまたでは「memoは使わなくても良い」という記事もあります。
一方で「とりあえず全部メモ化せよ」という意見もあります。

私見としては、

  • (子コンポーネント)外からどのように参照されたとしても、自分自身はメモ化する準備をしておく。
  • (親コンポーネント)子コンポーネントでメモ化の準備が行われているかもしれないので、とりあえずそっちに合わせる。
  • (親コンポーネント)今は少ない子コンポーネント数だからメモ化しなくてもあまり影響はないが、今後の機能拡張で子コンポーネントがどれくらい増えるかわからない。

等の理由から「とりあえず全部メモ化せよ」に1票って感じです。

ただし、せっかくメモ化するなら意図したように動くよう、propsなり第2引数なりに正しいパラメータを指定するよう気を付けたいです。
また、サンプルコードにも書いたようにconsole.log等を通じて意図したとおりにレンダリングが行われているかの確認も必要と思います。


以上です。

P.S.
別業務(とDiablo4)で1ヵ月ほどプログラミングから離れていました。
久々にコーディング再開しようと思ったら、どこまで進んでいて、何が保留になっていて、この先どう進めようとしていたかがまったく思い出せませんでした。
メモ、大事ですね。

Discussion