大量に行があるテーブルを編集させる時

2023/11/14に公開されました。
2023/11/26に更新されました。

大量に行があるテーブルで入力を行なうことができるwebアプリを作る時の話


author: komem3

スプレッドシートのようなWebアプリを作って欲しいという要望が来たことはありますか?
今回はスプレッドシートのような大量に行があり、入力もできるブラウザ殺しのアプリケーションを作ったさいの嵌りポイントを共有します。

今回は以下のような、表形式の入力ができるアプリケーションを、いい感じにしていきます。(create viteで作成しています)

import { Dispatch, SetStateAction, useState } from "react";
const manyRows = Array(4000)
  .fill("")
  .map(() => ({
    name: "ジーアイクラウド株式会社",
  }));

const App = () => {
  const [state, setState] = useState(manyRows);

  return (
    <table>
      <thead>
        <tr>
          <th>No</th>
          <th>名前</th>
          <th>アドレス</th>
          <th>住所</th>
          <th>画像</th>
        </tr>
      </thead>
      <tbody>
        {state.map((row, index) => (
          <Row row={row} index={index} setState={setState} key={index} />
        ))}
      </tbody>
    </table>
  );
};

const Row = function Row({
  row,
  index,
  setState,
}: {
  row: { name: string };
  index: number;
  setState: Dispatch<SetStateAction<(typeof row)[]>>;
}) {
  return (
    <tr key={index}>
      <td>{index + 1}</td>
      <td>
        <textarea
          name="name"
          value={row.name}
          onChange={(e) =>
            setState((cur) => [
              ...cur.slice(0, index),
              { name: e.target.value },
              ...cur.slice(index + 1),
            ])
          }
        />
      </td>
      <td>test@example.com</td>
      <td>東京都港区南麻布3-20-1 Daiwa 麻布テラス3F</td>
      <td>
        <img src="http://placehold.jp/150x150.png" />
      </td>
    </tr>
  );
};

export default App;

レンダリングによる入力遅延

真っ先につまずくポイントがこれです。 reactで普通に作成すると、編集の度に全ての行が再レンダリングします。 子Componentを分けており、入力により親のstateが変更される限りは避けては通れぬ道です。

reactのchrome拡張でレンダリングを見えるようにすると、ちょっと見辛いですが先のアプリケーションは入力の度に全レンダリング走っているのが分かります。

最初の状態

このような場合に真っ先にやるのはコンポーネントのmemoです。memoを行なうことで、propsに差分があるまでレンダリングが走りません。
上記のアプリだと以下のようにするだけです。

-const Row = function Row({
+const Row = memo(function Row({
   row,
   index,
   setState,

レンダリングが一行だけに限定されてます。

メモの状態

大分軽快な入力になっています。

また、memoでは第2引数に比較関数を指定できます。

optional arePropsEqual: A function that accepts two arguments: the component’s previous props, and its new props. It should return true if the old and new props are equal: that is, if the component will render the same output and behave in the same way with the new props as with the old. Otherwise it should return false. Usually, you will not specify this function. By default, React will compare each prop with Object.is.

どうしても渡したいpropsがあるけど入力の度に変更されてしまう、みたいな状況で役に立つので頭の片隅にあると助かります。

ブラウザが重くなることによる入力遅延

先程ので大分軽くなっていましたが、行数が多くなるとまた遅延が発生します。

modified   src/App.tsx
@@ -1,5 +1,5 @@
 import { Dispatch, SetStateAction, memo, useState } from "react";
-const manyRows = Array(4000)
+const manyRows = Array(10000)

ブラウザが重い

これは単純に描写されている要素が多過ぎてブラウザが重いという状況です。入力によるレンダリングをいくら頑張ってもどうしようもないです。

この場合、描写が多くて重いという原因なので、Intersection Observer APIを使用して実際に描写する要素を絞るというのはかなり有効です。

案1

例えば以下のように、現在見えているindexからの絶対値が一定値以下のもののみをレンダリングするようにする、という解決策が考えられます。

const maxViewRows = 1000;

const App = () => {
  const [state, setState] = useState(manyRows);
  // 現在見えているindexが入る
  const [viewIndex, setViewIndex] = useState(0);

  return (
    <table>
      <thead>
        <tr>
          <th>No</th>
          <th>名前</th>
          <th>アドレス</th>
          <th>住所</th>
          <th>画像</th>
        </tr>
      </thead>
      <tbody>
        {state.map((row, index) => {
          // 画面上で見えているindexに対して遠いものは描写しない
          if (Math.abs(index - viewIndex) < maxViewRows) {
            return (
              <Row
                row={row}
                index={index}
                setView={setViewIndex}
                setState={setState}
                key={index}
              />
            );
          }
        })}
      </tbody>
    </table>
  );
};

const Row = memo(function Row({
  row,
  index,
  setState,
  setView,
}: {
  row: { name: string };
  index: number;
  setState: Dispatch<SetStateAction<(typeof row)[]>>;
  setView: Dispatch<SetStateAction<number>>;
}) {
  const ref = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        // 画面に入ったindexをsetしている
        if (entry.isIntersecting) {
          setView(index);
        }
      });
    });

    const target = ref.current;
    if (target != null) {
      observer.observe(target);
    }

    return () => {
      if (target != null) {
        observer.unobserve(target);
      }
    };
  }, [setView, index]);

  return (
    <tr key={index} ref={ref}>

とても軽くなります。

表示を減らす

ただこの方法を使用すると以下のようにスクロールバーの挙動が微妙になります。滅茶苦茶軽くなった代償です。

スクロール

案2

他に考えられる方法として、見えていない行の描写を最低限にする、という方法が考えられます。
先の方法は描写する行数を絞っていましたが、この方法は描写する要素を絞る感じです。

// 前の状態に戻している
     <tbody>
        {state.map((row, index) => (
          <Row row={row} index={index} setState={setState} key={index} />
        ))}
      </tbody>

// ...
const Row = memo(function Row({
  row,
  index,
  setState,
}: {
  row: { name: string };
  index: number;
  setState: Dispatch<SetStateAction<(typeof row)[]>>;
}) {
  const ref = useRef(null);
  // 画面に入っているかどうかフラグ
  const [isView, setView] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          // 画面に入ったら見えるフラグがtrueに、抜けたらfalseになる。
          setView(entry.isIntersecting);
        });
      },
      { rootMargin: "300px" },
    );

    const target = ref.current;
    if (target != null) {
      observer.observe(target);
    }

    return () => {
      if (target != null) {
        observer.unobserve(target);
      }
    };
  }, [setView, index]);

  return (
    <tr key={index} ref={ref} style={{ height: "150px" }}>
      {isView && (
        <>
          <td>{index + 1}</td>
          <td>
            <textarea

ちょっと重いです。

行を最低限

中身空でも描写しているだけあってスクロールの挙動は完璧です。

同期スクロール

双方の欠点

どちらも軽くなる代償としてctrl-f が効かなくなります。それこそスプレッドシートのようにctrl-fを押すと検索できるようにしてあげる必要が出てくる可能性があります 🥲。

まとめ

今回は、スプレッドシートのようなものを作って、という状況になったさいのレンダリングによる嵌りポイントとブラウザによる嵌りポイントの解決案を紹介しました。
実際スプレッドシートはtableタグなどを使用せずjavascriptで描写している様なので、本当に同様のものを目指すなら今回のアプローチは嵌らない可能性があります。
でも、誰かの役に立つと幸いです。

ページング…?

※本記事は、ジーアイクラウド株式会社の見解を述べたものであり、必要な調査・検討は行っているものの必ずしもその正確性や真実性を保証するものではありません。

※リンクを利用する際には、必ず出典がGIC dryaki-blogであることを明記してください。
リンクの利用によりトラブルが発生した場合、リンクを設置した方ご自身の責任で対応してください。
ジーアイクラウド株式会社はユーザーによるリンクの利用につき、如何なる責任を負うものではありません。