react-window で巨大なリストを低コストに表示する

**この記事ではReactで巨大なリストを効率よく表示するためのライブラリ `react-window` をご紹介します。**

似たようなライブラリに `react-virtualized` というものがありますが、`react-window` は同じ作者による新しいライブラリです。

## はじめに

ナイーブな方法では、長大なリストを表示するにはその長さに比例するだけのレンダリングコストがかかります。ナイーブな方法とは下記のようにコンポーネントのリストを作って表示するだけの方法です。

```jsx:Naive
import React from "react";
import styled from "styled-components";

const Container = styled.section`
  display: flex;
  flex-direction: column;
`;

const RowContainer = styled.div`
  position: relative;
  width: 100px;
  height: 100px;
`;

const RowName = styled.div`
  position: absolute;
  bottom: 0;
  width: 100%;
  padding: 2px 8px;
  background-color: black;
  color: white;
  box-sizing: border-box;
`;

const RowImage = styled.img`
  width: 100%;
  height: 100%;
`;

const Row = ({ name, url }) => (
  <RowContainer>
    <RowName>{name}</RowName>
    <RowImage src={url} />
  </RowContainer>
);

const ITEM_NUM = 1000;

export default function App() {
  const rows = new Array(ITEM_NUM)
    .fill(0)
    .map((_, j) => (
      <Row
        key={j}
        name={`photo-${j}`}
        url={`https://picsum.photos/id/${j}/200/300`}
      />
    ));

  return (
    <div className="App">
      <Container>{rows}</Container>
    </div>
  );
}
```

要素の数を変えて何回か実行してみたところ次のような結果になりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3387/4012604c-8862-8255-c803-8cb4f202e421.png)

**Renderingにかかる時間**

|100 items|500 items| 1000 items|
|--:|--:|--:|
|18.9 ms|119.0 ms|425.3 ms|

要素数が多くなればなるほど時間がかかっています。シンプルなコンポーネントならともかく、レンダリングコストの大きいコンポーネントのリストについて考えるときは、こういった実装をする前に要素数について慎重に考えなければなりません。

## react-window

[react-window](https://github.com/bvaughn/react-window/) は表示領域に応じてレンダリングするコンポーネントを制限することで要素数の問題を解決します。`react-window` は次の4つのコンポーネントといくつかの拡張で構成されています。

- `FixedSizeList`
- `VariableSizeList`
- `FixedSizeGrid`
- `VariableSizeGrid`

### `FixedSizeList` & `VariableSizeList`

`FixedSizeList` は最も基本的なコンポーネントで、要素の数と要素の高さを指定することで表示する必要がある分だけのコンポーネントを描画してくれます。ナイーブな方法からの変更点は次のようになります。

注意点は、`FixedSizeList`のchildrenとして要素コンポーネント(`Row`)を渡すということと、要素コンポーネントのルート要素に `style` を渡すということです。要素の高さは`FixedSizeList`が決定するため、**`style`を渡さないと要素の高さが固定できません。**

```jsx:FixedSizeList
const Row = ({ index, style }) => (
  <RowContainer style={style}>
    <RowName>{items[index].name}</RowName>
    <RowImage src={items[index].url} />
  </RowContainer>
);

const ITEM_NUM = 1000;

const items = new Array(ITEM_NUM).fill(0).map((_, j) => ({
  name: `photo-${j}`,
  url: `https://picsum.photos/id/${j}/200/300`
}));

export default function App() {
  return (
    <div className="App">
      <FixedSizeList
        height={1000}
        width={100}
        itemSize={120}
        itemCount={items.length}
      >
        {Row}
      </FixedSizeList>
    </div>
  );
}
```

`FixedSizeList`についてもナイーブな実装方法と同様に実行時間を計測してみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3387/5e72bf3a-fdeb-2e87-e289-a1b1a0e117b3.png)

**Renderingにかかる時間**

|100 items|500 items| 1000 items|
|--:|--:|--:|
|9.9 ms|10.4 ms|9.9 ms|

要素数が大幅に増えても実行時間がほとんど変わらなくなりました！

### `FixedSizeGrid` & `VariableSizeGrid`

リストだけでなくテーブルにも応用できます。次元が増えるだけなのでコードは割愛します。

## `InfiniteLoader`

[react-window-infinite-loader](https://github.com/bvaughn/react-window-infinite-loader)の `InfiniteLoader` を使うことで、無限スクロールが実現できます。

いくつか注意点があります。

1. `InfiniteLoader`の中で`FixedSizeList`を呼んでいます。`FixedSizeList`が要素のレンダリングイベントをキャッチして`InfiniteLoader`が要素のロードを行っているというイメージです。
2. `InfiniteLoader`に渡す`itemCount`はリモートにあるデータ全体を含めた上での長さです。それがわかるならその値を指定すればいいですが、わからない場合は適当に大きい値を設定してください（ここではローカルにあるitemsの長さ+1の長さを指定しています）。

```jsx:InfiniteLoader-part-1
const Row = ({ data, index, style }) => {
  const itemLoading = index >= data.length;

  if (itemLoading) {
    // return loading component
  } else {
    return (
      <RowContainer style={style}>
        <RowName>{data[index].name}</RowName>
        <RowImage src={data[index].url} />
      </RowContainer>
    );
  }
};

const List = ({ items, moreItemsLoading, hasNextPage, loadMoreItems }) => {
  // リモートの分も含めたデータの本質的な数を指定する必要がある。わからなければ適当に大きい値でも良い。
  const itemCount = hasNextPage ? items.length + 1 : items.length;

  console.log(items.length);

  return (
    <InfiniteLoader
      isItemLoaded={index => index < items.length - 1}
      itemCount={itemCount}
      loadMoreItems={loadMoreItems}
    >
      {({ onItemsRendered, ref }) => (
        <FixedSizeList
          height={1000}
          width={100}
          itemCount={itemCount}
          itemSize={120}
          onItemsRendered={onItemsRendered}
          ref={ref}
        >
          {Row}
        </FixedSizeList>
      )}
    </InfiniteLoader>
  );
};
```

以下は`InfiniteLoader`のラッパーを呼ぶ部分です。データをフェッチする処理を適当に定義してラッパーに渡します。

```jsx:InfiniteLoader-part-2

const ENDPOINT = "https://api.thecatapi.com/v1/images/search";
const LIMIT = 10;

export default class App extends React.Component {
  state = {
    items: [],
    moreItemsLoading: false,
    page: 0
  };

  async loadMoreItems(startIndex, stopIndex) {
    console.log("Loading...");

    this.setState({ moreItemsLoading: true });

    const response = await fetch(
      `${ENDPOINT}?limit=${LIMIT}&page=${startIndex / LIMIT}`
    );
    const data = await response.json();
    const items = data.map(e => ({ name: `cat-${e.id}`, url: e.url }));

    this.setState({
      moreItemsLoading: false,
      page: this.state.page + 1,
      items: this.state.items.concat(items)
    });
  }

  render() {
    return (
      <List
        items={this.state.items}
        moreItemsLoading={this.state.moreItemsLoading}
        hasNextPage={true}
        loadMoreItems={this.loadMoreItems.bind(this)}
      />
    );
  }
}
```

はじめにこれを見たときはトリッキーな書き方だと感じましたが、拡張は容易そうです。

## `react-virtualized` との違いは？

同じ作者からほぼ同じ機能を持つライブラリ `react-virtualized` が公開されています。作者曰く、こちらは長らく開発されてきたプロジェクトで、本質的でない機能がたくさんあり動作が遅くパッケージが巨大になってしまっているそうです。`react-window` が2KBほどのサイズに対して `react-virtualized` は33.5KBほどあります。今から新しいプロジェクトを開発する場合は、ほとんどの場合で`react-window`を使ったほうが良いようです。

## References

参考

- https://github.com/bvaughn/react-window/
- https://github.com/bvaughn/react-window-infinite-loader

記事に記載したコードのソース

- https://github.com/karszawa/react-window-handson