🚀

ReduxToolkitのcreateEntityAdapterを使ったパフォーマンス改善

2023/12/09に公開

※ この記事は Cybozu Frontend Advent Calendar 2023 の 9日目の記事です。

こんにちは!サイボウズ株式会社で kintone というプロダクトのフロントエンドエンジニアをやっている Nokogiri です。

kintoneの画面の一部ではフロントエンドの状態管理に ReduxToolkit を採用しています。

Reduxの採用理由は以下の記事をご参照ください

https://blog.cybozu.io/entry/2023/04/20/190000

今回は kintone のフロントエンド開発でReduxアプリのパフォーマンスをよくするためにやったことについて紹介します。

前提

Reduxアプリのベストプラクティスについてはこのページに記述されてあります。今回パフォーマンスを改善するための取り組みも以下のプラクティスをもとに行いました。より詳細を確認したい方は公式サイトをご覧ください。

https://redux.js.org/style-guide/

配列操作の代わりにcreateEntityAdapterを使う

createEntityAdapter とは 正規化された状態の構造に対してCRUD操作を行うためのreducerやselectorを生成する機能です。

どのような課題があって、それをどう改善したか見ていきます。

配列の一部を更新すると全体が再レンダリングされる

n 件あるデータをループを回して表示する場合配列を利用することが多いと思います。

type Item = {
  itemId: number;
  name: string;
  published: string;
  author: string;
};

const ListItem = ({ item }: { item: Item }) => {
  return (
    <li>
      <div> Name : {item.name} </div>
      <div> published: {item.name} </div>
      <div> author : {item.name} </div>
    </li>
  );
};

const List = () => {
  const items = useSelector(state => state.items)
  return (
    <ul>
      {items.map((item) => (
        <ListItem item={item} key={item.itemId} />
      ))}
    </ul>
  );
};

items が不変であれば問題ないのですが items の中身が可変でかつ ListItem から更新される場合にList全体が再レンダリングされるという問題があります。

例えば ListItem の中で以下のように name を変更する仕様があった場合、name が変更されるたびに List コンポーネント全体が 再レンダリング されます。自ずと子供の component もすべて再レンダリングされます。

const ListItem = ({ item }: { item: Item }) => {
  const dispatch = useDispatch();
  const changeValue = (value: string) => {
    dispatch(actions.changeNameValue({itemId: item.id, name: value }));
  };

  return (
    <li>
      <div>
        Name :
        <input
          type="text"
          onChange={(e) => changeValue(e.target.value)}
          value={item.name}
        />
      </div>
      <div> published: {item.name} </div>
      <div> author : {item.name} </div>
    </li>
  );
};

React Developer Tools の 「Highlight updates when components render.」 で確認可能です

gif

これは useSelector で参照している items に変更が入るため、itemsを参照している List 全体が再レンダリングされるのが原因です。

このような事例に対処するには 一つ一つの要素で memo などを使ってキャッシュすることが一般的ですが、今回のように親の Listitems 全体を参照していることで発生する問題のためこの方法は利用できません。

再レンダリングを防ぐためにやること

以下の変更を行うことで再レンダリングを防ぐことにします。

  • List は items 全体を参照するのをやめ item を一意に識別できるキー(itemId)だけを参照する
  • Item は List から item ではなく itemId だけをもらい selector を利用して item を参照するようにする
  • item を一意に識別できるキーと item を別のデータ構造として個別に管理する

上記の修正を List のまま行うこともできるのですが、キーと実態を別のデータ構造として管理すると要素の追加更新時に二重メンテになるなどデメリットも多いです。パフォーマンスを良くするためとはいえ複雑さをどこまで許容するかはトレードオフになります。

createEntityAdapter を使って対処する

createEntityAdapterはこのような問題を解決するためのインターフェースを備えています。

createEntityAdapterを使うことでもともと配列だったデータ構造は以下のようなデータ構造になります。
ids は その要素を一意に識別できるキー
entitiesid をキーにしており item 自体を持つ

{
  ids: [1, 2, 3],
  entities: {
    "1": {
      itemId: 1,
      name: "DragonBallabcde",
      published: "1984",
      author: "Akira Toriyama",
    },
    "2": {
      itemId: 2,
      name: "Yuyuhakusho",
      published: "1990",
      author: "Yoshihiro Togashi",
    },
    "3": {
      itemId: 3,
      name: "SLAM DUNK",
      published: "1990",
      author: "Takehiko Inoue",
    },
  },
};

List コンポーネントで ids を参照し、ListItem コンポーネントで id を使って entities から取得することで親と子が同じデータ構造を参照することを防ぎます。

もちろん要素自体が追加削除された場合は ids に変更があるため再レンダリングは発生します。実際にリストの要素が増えたり減ったりするなら妥当な再レンダリングだと判断できます。

createEntityAdapter を使った実装の場合は以下のように再レンダリングを防いでいます。

entity

createEntityAdapter を使った具体的な実装

state のデータ構造は以下のように変わります。

// old
type State = {
  items: Item[]
}

// new
type State = {
  items: EntityState<Item>
}

adapterselector を生成します。

export const itemAdapter = createEntityAdapter<Item>({
  selectId: (model) => model.itemId,
});

export const itemSelector = itemAdapter.getSelectors<RootState>(
  (state) => state.example.items
);

List コンポーネントは itemSelector#selectIds を使って state から ids だけを参照します。 だけを参照します。

const List = () => {
  const itemIds = useSelector(itemSelector.selectIds);
  return (
    <ul>
      {itemIds.map((id) => (
        <ListItem itemId={Number(id)} key={id} />
      ))}
    </ul>
  );
};

ListItem 側では itemSelector を使って itemId から item を参照します

  // ListItem
  const item = useSelector((state: RootState) => itemSelector.selectById(state, itemId)) // item | undefined

item は理論上存在するはずなので assert してあげてもよいです。

あとがき

今回は、配列の代わりにcreateEntityAdapterを使ってReactアプリで再レンダリングを防ぐ仕組みを紹介しました。
途中でも触れましたが、この実装を取り入れることでコード量は増え、複雑性は上がっておりパフォーマンスとトレードオフになっています。
ListItem の中身が可変でかつ再レンダリングを抑えたい場合にこの実装は有用だと考えています。

▼Cybozu Frontend Advent Calendar はこちら

https://adventar.org/calendars/9255

サイボウズ フロントエンド

Discussion