React Virtualizedとお別れした話 – Sansan Tech Blog

こんにちは、名刺アプリ「Eight」でエンジニアをしている藤野です。最近はタコスの肉(カルニタス)作りに挑戦しようと考えています。

さて、今回はReact 18移行時に発生したReact Virtualizedで起こった問題と、その解決のためにReact Virtuosoへ移行した技術的な判断について共有します。

そもそもReact Virtualizedって?

React Virtualizedは、大量のリストやテーブルデータを扱う際に、画面に表示されている項目だけを仮想的に描画することで、描画負荷とメモリ使用量を削減するReact向けライブラリです。

github.com

画面に見えている領域だけDOMとして描画し、スクロールに応じて動的にDOMを追加・削除することで、数千、数万件のデータでも快適にスクロールできるようにする仕組みです。基本的なコンポーネント(List、Grid、Table)やセルのサイズの動的計算のためのCellMeasurerなど、様々なコンポーネント群を提供しています。

Eightでの活用例では、採用サービスであるECD(Eight Career Design)の候補者検索画面が挙げられます。

候補者検索画面

ECDでは、大量の候補者情報を無限スクロールで表示する必要があり、React Virtualizedを活用していました。

発生した問題

React 18移行後、無限スクロールを実装している画面で「スクロールが勝手に上方向に戻る」という深刻なバグが発生しました。

github.com

原因はReact 18で導入されたAutomatic Batchingにありました。Automatic Batchingは、複数の状態の更新をまとめて1回で行うこと(バッチ処理)で、再レンダリングの回数を削減し、パフォーマンス向上をさせる機能です。

function handleClick() {
  setCount(count => count + 1; 
  setFlag(flag => !flag);     
  
}


function handleClick() {
  setCount(count => count + 1; 
  setFlag(flag => !flag);     
  
}

このように、stateの更新タイミングが少なくなるため、実装によっては意図しない挙動をする可能性があります。React Virtualizedの高さを自動計算してくれるCellMeasurerコンポーネントはそれに該当しており、結果バグのような挙動が起きていました。

  1. 仮のサイズでセルをレンダリング
  2. 実際のDOM要素のサイズを測定
  3. キャッシュに保存して再レンダリング

このプロセスがバッチ処理されることで測定タイミングがずれ、スクロール位置の計算が狂ってしまう事象が発生していました。

また、そもそもこのReact Virtualizedというライブラリ自体、あまり活発に開発が行われていませんでした。これらの理由から、ライブラリの移行先を検討することになりました。

代替ライブラリの技術的比較

移行先として、以下のライブラリを技術的観点から比較検討しました。

比較表

観点 react-virtualized react-virtuoso @tanstack/react-virtual react-window
動的高さ対応 CellMeasurer 自動計算 useWindowVirtualizer (Ref) 手動測定必要
React 18対応 ❌ 非互換 ✅ 完全対応 ✅ 完全対応 △ 基本機能のみ
無限スクロール InfiniteLoader 標準搭載 useInfiniteQuery react-window-infinite-loader
開発状況 2年以上更新なし 活発(週次更新) 活発 低活動
インターフェースの類似度 似ている 異なる 似ている

React Virtualized後継のreact-windowも検討しましたが、同様にメンテナンスが活発ではないため同じ轍を踏まないよう、今回は不採用としました。

最終的にはReact Virtuosoと@tanstack/react-virtualとの比較になりましたが、今回はReact Virtuoso が採用されました 🎉 
表にはありませんが、ライブラリ移行の観点でのインターフェースの類似度や、要素の動的な高さ対応の容易さを考慮した結果となります。特に要素の動的な高さ対応に関して、Eightでは要素の高さが動的な無限スクロールUIが多いため、そこが大きな決め手となりました。

React VirtuosoとReact Virtualizedの比較

実際のコードで双方の記述を比較していきます。今回想定する「動的な高さのアイテムを無限スクロールするリスト」について実現するコードを示します。

React Virtualizedでの記述

主にAutoSizer + List + CellMeasurer + InfiniteLoader という4つのコンポーネントを組み合わせる必要があります。 見てわかる通り、コード量も依存コンポーネントも多くなり、実装がやや複雑です。

{({ index }=> !!items[index]}
  loadMoreRows={loadMore}
>
  {{ onRowsRendered, registerChild }) => (
    <AutoSizer>
      {{ height, width }) => (
        {registerChild}
          onRowsRendered={onRowsRendered}
          height={height}
          width={width}
          rowCount={items.length}
          rowHeight={cache.rowHeight}
          rowRenderer={({ index, key, parent, style }) => (
            {cache}
              columnIndex={0}
              key={key}
              parent={parent}
              rowIndex={index}
            >
              {{ registerChild, measure }) => (
                <div ref={registerChild} style={style}>
                  {items[index]}
                    onLoad={measure} 
                  />
                div>
              )}
            CellMeasurer>
          )}
        />
      )}
    
  )}

  • InfiniteLoader で無限スクロールの判定と 追加fetchのためのloadMoreRows を実装
  • AutoSizer でコンテナサイズを計算し、子に渡す
  • CellMeasurer で動的なアイテム高さを測定

React Virtuosoでの実装例

動的な高さのアイテムの実装はたった数行に収めることができます✨

{items}
  itemContent={index, item) => <ItemComponent item={item} />}
  endReached={loadMore}
/>
  • 無限スクロールは endReached のPropsに渡すだけでOK
  • 高さは自動計測(CellMeasurer不要)
  • AutoSizer のようなWrapperも不要(内部でサイズ調整をしてくれる)

このように、細かな高さ計算などは全てライブラリが担保してくれるため、使う側のコードは非常にシンプルになり、移行計画も立てやすくなります。

項目 React Virtualized React Virtuoso
サイズ計算 AutoSizer 必要 不要(内部対応)
高さ可変対応 CellMeasurer 必要 自動対応
無限スクロール InfiniteLoader 必須 endReached 一行で実現
実装の複雑さ コンポーネントを組み合わせる必要あり 単一コンポーネントで完結

段階的な移行

React Virtualized からの移行時、React Virtuoso をラップしたコンポーネントを用意しました。

type Props<T> = {
  items: T[];
  totalCount: number;
  isItemLoaded: (index: number) => boolean;
  loadMoreItems: () => Promise<void> | void;
  children: (props: { index: number; item: T | null }) => React.ReactNode;
  renderLoadingItem?: (index: number) => React.ReactNode;
};

export const InfiniteScrollListContainer = <T,>({
  items,
  totalCount,
  isItemLoaded,
  loadMoreItems,
  children,
  renderLoadingItem,
}: Props<T>) => {
  const data = [...items, ...Array(totalCount - items.length).fill(null];

  return (
    <Virtuoso<T | null>
      data={data}
      itemContent={index, item) =>
        !isItemLoadedindex) && renderLoadingItem
          ? renderLoadingItemindex)
          : children({ index, item }}
      rangeChanged={({ endIndex }=> {
        if (endIndex >= items.length - 10{
          loadMoreItems();
        }
      }}
    />
  );
};

このラッパーコンポーネント作成時のポイントは以下の通りです。

  • 既存のInfiniteLoader APIに似せてisItemLoadedやloadMoreItemsをprops化
  • totalCountを受け取って「未ロード部分はnullで埋める」仕組みを導入
  • VirtuosoコンポーネントのrangeChangedを使って「しきい値を超えたらloadMoreItems呼び出し」を実装

これにより、既存コードに大きな変更を加えずにReact VirtualizedからReact Virtuosoに置き換えが可能になります。また、内部的な挙動を隠蔽できるため作業者は今まで通りのInfiniteLoader的なAPIとしてコンポーネントを扱うことができます。I/Fを模した中間層を設けておいたことで、大きな問題なく移行を完遂することができました🎉

まとめ

今回は、React 18移行で発生したReact Virtualizedのスクロールバグと、それをきっかけにReact Virtuosoへ移行した経緯について紹介しました。

React Virtualized自体すごく便利なライブラリでしたが、メンテナンスが止まってしまっている以上、今後のReactアップデートに追従していくのは現実的に難しいです。今回のようにフレームワークのメジャーアップデートによって、新しい挙動が入るたびに壊れてしまうリスクもあります。その点、React Virtuosoはメンテナンスも活発で、動的な高さの自動計測や無限スクロール対応が標準で入っているので、Eightのように大規模なリスト表示を多用するサービスには相性が良いと感じました。

実際に移行してみるとコードもかなりシンプルになり、開発体験が改善されたのも大きな収穫でした。こうした「ライブラリ選定の再考・置き換え」は後回しにされがちですが、長期的には保守性や開発効率に直結する部分なので、良いタイミングで取り組めたなと感じています。

Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。




Source link

関連記事

コメント

この記事へのコメントはありません。