Reactを使ってtable要素に無限ローディングを実装することになった

最初に

テーブルレイアウト無限ローディング

今回、開発期間が半年ほどの新機能追加のタスクに参加して、新規画面の実装を行いました。

そこで table 要素を使って組んだレイアウトに無限ローディング機能 + ユーザが選択した要素を別テーブルで保持する機能の実装を行いました。

どのように実装したか紹介しようと思います。

仕様

まず実装前にデザイナーから Figma で作成された UI と共に以下のような要望がありました。

  1. テーブルのレイアウトが 2 つ並んでいる

  2. 左のテーブルに要素の一覧がある。クリックするとチェックボックスがONになり、右のテーブルにXアイコンと共に表示される

  3. 追加された値を消すにはXアイコンを押すか、チェックボックスをオフにする

2 つのテーブルはまさに「運命共同体だ!!」という感じでした。
これだけだと、実装するのは難しかったため、デザイナーと話し合いながら細かい仕様を決めていきました。

  • API から一覧の情報が返るので、取得した値を左のテーブルに表示
  • 一度に表示される値は 10 件まで
  • テーブルはデータ追加に伴い、一定の高さを超えるとデータをスクロールして閲覧する形にする
  • 検索結果が 10 件以上の場合は下までスクロールされた際に追加で 10 件取得
  • 1 万件までは 右のテーブルに追加されることを想定
  • 右のテーブルには多くても 10 件ほどしか追加されない
  • 取得した要素がクリックされたらチェックボックスを ON する
  • チェックボックスが ON になったら、右のテーブルにクリックした値をXマークと共に追加
  • チェックボックスを OFF にすると、右のテーブルから OFF にした値が消える
  • Xマークをクリックすると、右のテーブルからクリックした要素が消え、左テーブルにあるチェックボックスを OFF にする

こんな感じにある程度、仕様を固めて実装に入りました。

実装

業務で書いたコードをそのまま載せられないので、ポケモン API を使って似たような機能を実装しました。

リポジトリ - テーブルレイアウト無限ローディング

こちらの実装したコードを使って、解説出来たらと思います。

# clone
git clone https://github.com/wimpykid719/react-component.git

# パッケージのインストール
npm install

# アプリ起動
npm start

データ構造

無限ローディングによって、取得したデータがどんどん配列へ追加されていきます。
そのため、全てのデータを 1 つの配列で保持してしまうと、操作してデータが追加されるたびにチェックボックスの状態更新の処理が重くなっていくと考えました。

なのでデータを 2 つの配列と連想配列(オブジェクト) の 3 つに分けて保持するようにしました。

イメージとしては

// 選択可能なポケモン一覧
// 右テーブルに表示する選択肢一覧の並び順と連想配列でデータ操作する際のキーを配列で保持している
// この配列をmapしながらデータを表示
const pokemonNames = ['キー1', 'キー2', 'キー3', 'キー4'...]

// 連想配列に右テーブルで必要なデータを保持
// こうする事で選択可能なポケモンが増えていっても、データ操作はキーを使って行えるので、配列操作よりも早い
const pokemonObj = {
 キー1: {
         name: 'ポケモン名前',
         url: '詳細なURL',
         checked: false, // チェックボックスの状態
        },
 キー2: {
         name: 'ポケモン名前',
         url: '詳細なURL',
         checked: false,
        },
 キー3: {
         name: 'ポケモン名前',
         url: '詳細なURL',
         checked: false,
        },
  キー4: {
         name: 'ポケモン名前',
         url: '詳細なURL',
         checked: false,
        },
}

// 選択中のポケモン一覧
// こちらはそこまでデータが追加されない仕様のため配列で保持している
const checkedPokemons = [
 {name: 'ポケモン名前', url: '詳細なURL'},
 {name: 'ポケモン名前', url: '詳細なURL'},
 {name: 'ポケモン名前', url: '詳細なURL'},...
]

こんな感じにすることでデータ量が増えていっても、チェックボックスの状態を操作する処理はそこまで遅くならないようにしました。

テーブルレイアウト - 無限ローディング

左のテーブルレイアウト

<TableWrapper onScroll={onScroll}>
  <Table ref={tableRef}>
    <Thead>
      <Tr className="TrTh">
        <Th>ポケモン名</Th>
        <Th>詳細URL</Th>
      </Tr>
    </Thead>
    <Tbody>
      {pokemonNames.length === 0 ? (
        <Tr>
          <Td colSpan={2}>
            <NoPokemon>
              {isError
                ? "ネットワークエラー、ポケモンを取得できません"
                : "ポケモンが表示されます"}
            </NoPokemon>
          </Td>
        </Tr>
      ) : (
        pokemonNames.map((name) => (
          <SerachedTr
            className="TrTd"
            key={name}
            onClick={
              pokemonObj[name]?.checked
                ? () => removePokemon(pokemonObj[name]?.name)
                : () => addPokemon(pokemonObj[name])
            }
          >
            <Td>
              <Checkbox
                checked={!!pokemonObj[name]?.checked}
                label={pokemonObj[name]?.name || ""}
              ></Checkbox>
            </Td>
            <Td>{pokemonObj[name]?.url}</Td>
          </SerachedTr>
        ))
      )}
      {!isLoading && (
        <Tr>
          <TdLoading colSpan={2}>
            <LoaderWrapper>
              <LoadingCircle />
            </LoaderWrapper>
          </TdLoading>
        </Tr>
      )}
    </Tbody>
  </Table>
</TableWrapper>

table 要素を div 要素(TableWrapper)で囲っています。
理由としては table 要素は border-raidus が指定出来ないのでラッパーを使ってテーブルの角丸を作ることにしました。

table要素角丸

そして onScroll をラッパーに設定しました。
テーブルがスクロールされる度に指定したメソッドが発火するようになっています。

const SCROLL_HEIGHT = 576;
...

const TableWrapper = styled.div`
  overflow-x: auto;
  max-width: 432px;
  width: 100%;
  border-radius: 4px;
  border: solid 1px #ccc;
  max-height: ${SCROLL_HEIGHT}px;
  overflow-y: scroll;
  margin-top: 20px;
  @media screen and (max-width: 1279px) {
    max-width: 600px;
  }
  @media screen and (max-width: 720px) {
    min-width: 290px;
  }
`;

TableWrapper は最大の高さが 576px になっていて中の table 要素がそれ以上になる時にスクロールできるようになっています。
※要素がテーブルヘッダ + 10 件の時に 576px 超えるようになっている。

スクロール検知に使用される買う要素の高さ

const onScroll = async (e: React.UIEvent<HTMLDivElement>) => {
  if (!tableRef.current) return;
  const { scrollHeight, scrollTop, clientHeight } = e.currentTarget;
  if (
    clientHeight + scrollTop !== scrollHeight ||
    isLoading ||
    !url ||
    tableRef.current?.clientHeight < SCROLL_HEIGHT
  )
    return;
  await execFetchPokemon();
};

スクロールが検知されると上記のメソッドが実行されます。
TableWrapper の各要素の高さを取得して判定に利用しています。

  • scrollHeight(紫の範囲): 隠れてスクロールできる要素全てを足した高さ
  • clientHeight(青の範囲): 見えている要素の高さ。10 件のデータが入っている時はmax-height: 576px;を指定しているので576 になる
  • scrollTop(黄色の範囲): スクロールして上に隠れた部分の高さ。スクロールすると値が増えていく

なので clientHeight + scrollTop を足して scrollHeight と同じになる時に最下部まで要素がスクロールされたと判定することができます。この時、新たにデータを取得することで無限ローディングを実現しました。

判定の内容

  • スクロールが最下部ではない
  • ローディング中
  • url が falsy な値
  • table 要素の高さが 576px 未満

上記の際は無限ローディングが実行されないようにしました。

const execFetchPokemon = async () => {
  setIsLoading(true);
  await fetchPokemon(url);
  setIsLoading(false);
};
...

const fetchPokemon = async (url: string | undefined) => {
  try {
    if (!url) return;
    const response = await fetch(url);
    const data: PokemonFetchedData = await response.json();
    const nextUrl = data.next || undefined;
    setUrl(nextUrl);
    setPokemonObj((prePokemonObj) => {
      const newPokemonObj = pokemonSelectCheckedObj(
        data.results,
        checkedPokemons
      );
      return { ...prePokemonObj, ...newPokemonObj };
    });
    setPokemonNames((prePokemonNames) => {
      return [
        ...prePokemonNames,
        ...data.results.map((result) => result.name),
      ];
    });
  } catch {
    setIsError(true);
  }
};

取得処理が走ると state に保存された url を元に api にリクエストを投げます。受け取った値に次の url が含まれていたら state にセット。なければ undefined をセットします。

pokemonSelectCheckedObj で上で紹介したデータ構造にして、ポケモン名だけ配列も同時にステートにセットする。

ここまでが table 要素を使った無限ローディングになります。
ここから先は運命共同体チェックボックスの解説になります。なので React で無限ローディングを実装したい人はこれで実装する事ができると思います。

テーブルレイアウト - チェックボックス

チェックボックス

<SerachedTr
  className="TrTd"
  key={name}
  onClick={
    pokemonObj[name]?.checked
      ? () => removePokemon(pokemonObj[name]?.name)
      : () => addPokemon(pokemonObj[name])
  }
>
  <Td>
    <Checkbox
      checked={!!pokemonObj[name]?.checked}
      label={pokemonObj[name]?.name || ""}
    ></Checkbox>
  </Td>
  <Td>{pokemonObj[name]?.url}</Td>
</SerachedTr>

クリック出来る範囲を広くしたいので、実際にはクリック出来る要素はテーブルの行になります。
チェックのON / OFFによってクリック時に発火する 2 種類のメソッドを用意しています。

// クリックされた要素のpokemonObjのcheckをチェックONの状態にする
// 右のテーブルレイアウトに要素を追加する
const addPokemon = (pokemon: Pokemon | undefined) => {
  if (!pokemon) return;
  const pokemonName = pokemon.name;
  setPokemonObj((prePokemonObj) => {
    return {
      ...prePokemonObj,
      [pokemonName]: {
        name: prePokemonObj[pokemonName]?.name || "",
        url: prePokemonObj[pokemonName]?.url || "",
        checked: true,
      },
    };
  });
  setCheckedPokemons((preCheckedPokemons) => {
    const checkedPokemon = { name: pokemon.name, url: pokemon.url };
    if (!preCheckedPokemons) {
      return [checkedPokemon];
    } else {
      return [...preCheckedPokemons, checkedPokemon];
    }
  });
};

// クリックされた要素のpokemonObjのcheckをチェックOFFの状態にする
// 右のテーブルレイアウトから要素を削除する
const removePokemon = (pokemonName: Pokemon["name"] | undefined) => {
  if (!pokemonName) return;
  setPokemonObj((prePokemonObj) => {
    return {
      ...prePokemonObj,
      [pokemonName]: {
        name: prePokemonObj[pokemonName]?.name || "",
        url: prePokemonObj[pokemonName]?.url || "",
        checked: false,
      },
    };
  });

  setCheckedPokemons((preCheckedPokemons) => {
    return preCheckedPokemons.filter(
      (checkedPokemon) => checkedPokemon.name !== pokemonName
    );
  });
};

右のテーブルレイアウト

<TableWrapper>
  <Table>
    <Thead>
      <Tr className="TrTh">
        <Th>ポケモン名</Th>
        <Th>詳細URL</Th>
      </Tr>
    </Thead>
    <Tbody>
      {!checkedPokemons || checkedPokemons.length === 0 ? (
        <Tr>
          <Td colSpan={2}>
            <NoPokemon>選択中のポケモンが表示されます</NoPokemon>
          </Td>
        </Tr>
      ) : (
        checkedPokemons.map((checkedPokemon) => (
          <Tr className="TrTd" key={checkedPokemon.name}>
            <PokemonNameTd>
              <RemoveButton
                onClick={() => removePokemon(checkedPokemon.name)}
              />
              {checkedPokemon.name}
            </PokemonNameTd>
            <Td>{checkedPokemon.url}</Td>
          </Tr>
        ))
      )}
    </Tbody>
  </Table>
</TableWrapper>

右のテーブルには、左のテーブルでチェックボックスがONになった要素が表示されるようになっています。
RemoveButton をクリックすると removePokemon が実行されて左のテーブルのチェックボックスは OFF になり、右のテーブルから削除されるようになっています。

これで今回の仕様に沿った実装が出来ました!

最後に

無限ローディングをどうやって実装するんだろう...と最初は分からなかったのですが、なんとか実装出来てよかったです。
テーブルレイアウトに関しては苦手意識があって、はじめ既存のテーブルレイアウト(div で作られた)で実装しました。 既存のものにはレイアウト崩れがあって table 要素に書き直す事になった時は大変でした(ほとんどデザイナーの人に書いてもらった)。

その節はとても助かりました。ありがとうございます。
理解が足りていない部分もあったため、今回、自分の理解を深める目的も含めて会社のテックブログに解説記事を書いてみました。今後も工夫したコンポーネントなどを記事に出来たらと思います。

ここまで読んで頂きありがとうございました。

参考文献

Documentation - PokéAPI

Border style do not work with sticky position element