🐶

【React】メモ化をちゃんと実装してみた useMemo useCallback

に公開

はじめに

私は、今までReactでメモ化を実装していませんでした。

理由としては、以前記事をいくつか見て、可読性が悪くなる/頑張ってやったところであまりメリットが受けられない場合も、、などという内容を見た(割とこういった意見の方が多かった)ので、ひとまず「やらない」方針でやっていました。

しかし、ある実装をきっかけに、ちゃんとメモ化をしてみようと思いました。

メモ化したきっかけ

Google maps apiを使ったアプリを作った

食べログの簡易版マップのようなアプリを作りまして、機能としては、以下のイメージです。

  • マップ上でキーワード検索をして、近隣のお店を絞り込み表示することができる
  • お店の地点をクリックすると、お店の詳細情報がポップアップで表示される

このアプリを実装をするなかで、メモ化をしようと言う気持ちになりました。

アプリの実装イメージ

実装としては、シンプルなもので、以下のようなイメージです。

  • 親コンポーネント: キーワードと検索ヒットしたお店をuseStateで管理。子コンポーネントにstate関連の変数や関数を渡す
  • 子コンポーネント1:「お店の地点を表示するピン・ポップアップ」をお店配列でArray.map展開し、Google maps apiで生成したマップ上に表示する
  • 子コンポーネント2: キーワード検索できるコンポーネント。キーワードのstateが更新される

かなり簡素化したコードイメージです

type Shop = {
  id: string;
  name: string;
};

export function ParentComponent() {
  const [keyword, setKeyword] = useState('');
  const [shops, setShops] = useState<Shop[]>([
    { id: '1', name: 'Shop 1' },
    { id: '2', name: 'Shop 2' },
    { id: '3', name: 'Shop 3' },
  ]);

  const [selectedShop, setSelectedShop] = useState<Shop | null>(null);

  const selectShop = (shop: Shop | null) => {
      setSelectedShop(shop);
    };

  return (
    <GoogleMapsComponent>
      <SearchInput keyword={keyword} setKeyword={setKeyword} />
      <ShopList
        shops={shops}
        selectedShop={selectedShop}
        setSelectedShop={selectShop}
      />
    </GoogleMapsComponent>
  );
}

function SearchInput({
  keyword,
  setKeyword,
}: {
  keyword: string;
  setKeyword: (keyword: string) => void;
}) {
  return (
    <input
      type="text"
      value={keyword}
      onChange={(e) => setKeyword(e.target.value)}
    />
  );
}

function ShopList({
  shops,
  selectedShop,
  setSelectedShop,
}: {
  shops: Shop[];
  selectedShop: Shop | null;
  setSelectedShop: (shop: Shop | null) => void;
}) {
  return (
    <div>
      <p>選択された店舗: {selectedShop?.name}</p>
      {shops.map((shop) => (
        <ShopItem key={shop.id} shop={shop} setSelectedShop={setSelectedShop} />
      ))}
    </div>
  );
}

function ShopItem({
  shop,
  setSelectedShop,
}: {
  shop: Shop;
  setSelectedShop: (shop: Shop | null) => void;
}) {
  return <PinWithPopup shop={shop} onClick={() => setSelectedShop(shop)} />
}

無駄な再レンダリングが多発していることを見つけた

しかし、実装してみると、無駄なレンダリングが多発していました。

キーワード検索UIで、文字を打つたびに、全てのShopItemが、再レンダリングされてしまうという状態でした。

再レンダリングするロジックしては、、

  1. キーワードのstateが変わる
  2. 親コンポーネントが再レンダリングされる
  3. 子コンポーネントのShopItemも再レンダリング

という流れです。

キーワード検索で検索ボタンを押すまでは、お店の配列の中身は変わらないので、これは無駄だなと思い、メモ化することを決意しました。

次にメモ化で使うreactの機能のざっくり説明です。

memo/useMemo/useCallbackとは

この記事の主題ではないのでざっくり説明です。

memo

React.memoコンポーネントをメモ化し、props が変わらない限り再レンダリングをスキップする ための機能です。

export const MemoComponent = React.memo(function Component({ shops, onClickShop }) {
  // コンポーネントの実装
});

useMemo

useMemoは、計算結果をキャッシュするためのフックです。

計算や、配列や、オブジェクトをメモ化したいときに使います。

依存配列に変化がない限り、以前の計算結果を返してくれます。

const [values, setValues] = useState<Value[]>([]);

// valuesが変わらない限り、以前の結果を返す。
const memoValues = useMemo(() => values, [values]);

useCallback

useCallbackは「関数の参照をメモ化する」ためのフックです。

React コンポーネントは再レンダリングされるたびに、関数を新しく作り直しますが、以下のように依存配列が変わらない限り、キャッシュした関数を使います。

// memoValuesが変わらない限り、関数を新しく作らない。
const handleClick = useCallback(() => {
  console.log(memoValues);
}, [memoValues]);

改善部分の説明

全体コード

メモ化したバージョンの全体コードです

import { useState, useMemo, useCallback, memo } from 'react';

type Shop = {
  id: string;
  name: string;
};

export function ParentComponent() {
  const [keyword, setKeyword] = useState('');
  const [shops, _] = useState<Shop[]>([
    { id: '1', name: 'Shop 1' },
    { id: '2', name: 'Shop 2' },
    { id: '3', name: 'Shop 3' },
  ]);

  // useMemo
  const memoizedShops = useMemo(() => shops, [shops]);

  const [selectedShop, setSelectedShop] = useState<Shop | null>(null);
  const [keyword, setKeyword] = useState('');

  // useCallback
  const selectShop = useCallback(
    (shop: Shop | null) => {
      setSelectedShop(shop);
      // 色々処理が書かれている想定
      console.log('selectShop', shop?.id);
    },
    [],
  );

  return (
    <GoogleMapsComponent>
      <SearchInput keyword={keyword} setKeyword={setKeyword} />
      <ShopList
        shops={memoizedShops}
        selectedShop={selectedShop}
        setSelectedShop={selectShop}
      />
    </GoogleMapsComponent>
  );
}

function SearchInput({
  keyword,
  setKeyword,
}: {
  keyword: string;
  setKeyword: (keyword: string) => void;
}) {
  return (
    <input
      type="text"
      value={keyword}
      onChange={(e) => setKeyword(e.target.value)}
    />
  );
}

function ShopList({
  shops,
  selectedShop,
  setSelectedShop,
}: {
  shops: Shop[];
  selectedShop: Shop | null;
  setSelectedShop: (shop: Shop | null) => void;
}) {
  return (
    <div>
      <p>選択された店舗: {selectedShop?.name}</p>
      {shops.map((shop) => (
        <ShopItem key={shop.id} shop={shop} setSelectedShop={setSelectedShop} />
      ))}
    </div>
  );
}

// React.memo
const ShopItem = memo(function ShopItem({
  shop,
  setSelectedShop,
}: {
  shop: Shop;
  setSelectedShop: (shop: Shop | null) => void;
}) {
  return <PinWithPopup shop={shop} onClick={() => setSelectedShop(shop)} />
});

ポイント1.子コンポーネントをメモ化(React.memo)

コードで言うと、ShopItemの実装になります。

再レンダリングを抑制したいコンポーネントがこいつなので、メモ化します。

これで、propsが変わらない限り再レンダリングされなくなります。

propsが変わらない限り」と言うのが大事です。文字の通り、propsの中身が変わると再レンダリングされます。なので、propsに設定されている値も不要に再生成されないようにしなければいけません。不要に再生成されないようにするには、propsに設定されている値もメモ化をします。(自分ははじめこれがよく分かっておらず、メモ化してるのに再レンダリングされるじゃないか、、と思ってました。)

とはいえ、propsに入る値を何でもかんでもメモ化する必要はないのです。

プリミティブ(string/number/boolean)の場合は、メモ化しなくてOKです。

配列やオブジェクト、関数はメモ化をしないと再生成されてしまうので、メモ化をしましょう。

今回のサンプルコードのShopItemには、オブジェクトと関数を渡しているので、これらをメモ化します。

ポイント2.親コンポーネントでお店情報をメモ化(useMemo)

サンプルコードで言うと、shopsのメモ化です。こいつは、メモ化しているShopItemのpropsに設定されるオブジェクトになるので、メモ化しています。

オブジェクト / 配列の生成結果のメモ化は、useMemoを使います。

ポイント3.親コンポーネントでお店クリックイベントをメモ化(useCallback)

サンプルコードで言うと、selectShopのメモ化です。こいつは、メモ化しているShopItemのpropsに設定されている関数ですね。

前半で説明していた通り、関数のメモ化は、useCallbackを使います。

うまくいかない時

メモ化対象の依存先もチェックすること

メモ化しても、依存先の値が変更されると再レンダリングが走ります。例えば今回だと、React.memoを使ったShopItemですね。React.memoしたコンポーネントのpropsは注意してみる必要がありました。

その他のパターンだと、useCallbackとuseMemoの依存配列にセットしている値を注意するといいですね。

対象のコンポーネント直下でデバッグ

ちゃんとメモ化されているか確かめたい場合は、対象コンポーネントの直下でconsole.logでも書いておくと、レンダリングしたタイミングでconsoleが出るので、それでデバッグ確認ができます。

最後に

今までは「メモ化はやらない。」と決めていましたが、今回で使えるようになったと思いますので、今後は必要に応じてメモ化をしていければと思います。

Discussion