🍙

無限スクロールにポーリング処理実装しようとした時のまとめ

2024/01/30に公開

Reactで無限スクロールのテーブルビューに、常時状態を取得するポーリング処理を実装しようとして、計1ヶ月くらいつまづいたのでまとめておく。

ライブラリ

・RTKQuery
・react Virtuoso

まず

ポーリングと無限スクロールは相性が悪い。

ポーリング=定期的にAPIリクエストを投げること

無限スクロール=スクロールした分だけデータが読み込まれる

よって、どんどん増えていくデータに対して随時APIが投げられるので、対象となるデータも比例して増えていく。

状況

rtkQueryを使用してAPIを呼んでいる。

テーブルの項目はリストAPIで、ずらずら表示させている。

無限スクロール機能が実装されたテーブルに「ステータス」という項目がある。

こちらは、各項目がサーバーの裏側で処理されているステータスの状況を表している
status = 'processing'(処理中) か status = 'initialized' (初期状態)の時は、「処理中」が表示される。

また、サーバーの処理が終わり、ステータスが「処理中」から処理済み、またはエラーになった場合、画面を更新することなくステータスの表示を変更する。

最初にやったこと

・ステータスが「処理中」のものがあった場合、
 3秒ごとに全てのデータに対してポーリング処理を行いデータを更新する。

・処理中のものがなくなったらポーリングを終了する。

つまづいた点

その1

RTKQueryで作ったフックを使用してAPIを呼んでいたが、取得できるデータは100件までだった。

また、同じAPIを複数回呼んで結果を合体させる、ということはRTKQueryのフックを使用してできない。

フックを使わず自分でRTKQueryを使ってAPIを呼ぶ必要がある。

 const getListData = useCallback(
    async (newPage: number) => {
      const array= [];
      for (let count = 1; count <= newPage; count++) {
        try {
          const { data } = await dispatch(
            api.endpoints.getList.initiate({
              offset: (count - 1) * limit,
              limit: 10,
            })
          ).unwrap();
          if (data) {
            array.push(...data);
          }
        } catch (e) {
          console.log(e);
          return;
        }
      }
      setDataArray(array);
    },
    [dispatch, limit]
  );

initiateを使ってfor文を回し10件ずつ呼びたい分だけのAPIを呼びたくさんのデータを取得することに成功。

取得したデータを配列に格納する。

その2

APIをfor文で回すことに成功したが、APIの呼び出しは同期的なまま。

非同期で呼ばなければ、順番で呼ばれていく大量のAPIの終了を待たなければいけなくなる....

const getListData = useCallback(async () => {
    setIsLoading(true);
    try {
      const results = await Promise.all(
        Array.from({ length: page }, (_, index) => {
          return dispatch(
            api.endpoints.getList.initiate({
              offset: index * limit,
              limit: 10,
            })
          );
        })
      );
      if (results.length) {
        const array = results
          .map(({ data }) => data?.listData.flat())
          .flat()
          .filter((item): item is ListItem => !!item);
        setDataArray(array);
      }
    } catch (e) {
      console.log(e);
    } finally {
      setIsLoading(false);
    }
  }, [dispatch, limit, page]);

ということで、Promise.allを使用し、APIコールを非同期的に行うようにする。

Promise.allは、すべての処理を非同期で行い、すべての結果が帰ってきたら、その順番を維持したまままとめて結果を返してくれる。

その3

10件ずつデータを取得しているけれど、もしデータが1000件になったら、APIを100回も呼ばなければならない。

ナンセンスではないか?

  const getData = useCallback(
    async ({
      offset = 0,
      length = offset + rowsPerPage, // 指定のない場合、offsetから20件を取得する
      condition,
    }) => {
      setIsLoading(true);
      const requestTimes = Math.ceil((length - offset) / rowsPerPage);
      const array = [...Array(requestTimes)];
      try {
        const results = await Promise.all(
          array.map(async (_, index) => {
            return await dispatch(
             api.endpoints.getList.initiate(
                {
                  offset: offset + index * rowsPerPage,
                  limit: rowsPerPage,
                  ...condition,
                },
                {
                  forceRefetch: true,
                }
              )
            );
          })
        );
        const listData = results.flatMap(
          ({ data }) => data?.listData ?? []
        );
        const processingItem = listData.some(
          (item) =>
            item.status === 'processing' ||
            item.status === 'initialized'
        );
        setHasProcessing(processingItem);
        return {
          listData: listData,
        };
      } catch (e) {
        console.log(e);
      } finally {
        setIsLoading(false);
      }
      return {
        listData: [],
      };
    },
    [dispatch]
  );

ということでAPIの呼び出し回数を減らせるように試行錯誤。

1回のAPIコールで100件まで取得できるので、550件のデータを取得したい場合、100件取得APIを5回と50件取得APIの1回に分けた。

🍋次にやったこと

ここまで色々試してみたが、コードが複雑でわかりにくいと言われる。

どうしたものか。。。


😲

❗️

最初の20件を取得し、そこにステータスが処理中のアイテムがあったらポーリングするようにすればいいのでは?

ということで実装。

問題点

- 今までは、「処理中」のアイテムは必ずリストの一番上に表示されていた。
 
- しかしソートしたら順番が変わってしまう
  
・最初の20件の内に「処理中」が含まれるわけではなくなった。ステータスが「処理中」のアイテムが表のどこに現れるのか、予測できなくなった!

わーどうしよう。。。

そもそも

・ポーリング処理が本当に必要なのか?
・リアルタイムでステータスを更新しなければならないのか? etc

実装方法ではなく他のところを変えれないかなーと考えてみる

🍒完成に向けて

結局、難しいことはやめて、ただ単純にAPI取得のhooksを実装し、「処理中」のアイテムがあった時だけ3秒に1回全てのアイテムをポーリングする、という方向で進めていくこととなる。

(前提: すでにrtkqueryへのAPIの設定は済んでいる想定です)

  const getList = useCallback(
    async ({
      start = 0, // 取得するデータの最初の位置
      end = start + rowsPerPage, // 取得するデータの最後の位置。指定のない場合、startの値から20件とする
      forceRefetch = true,
    }) => {
      setIsLoading(true);
      // 取得するデータ総数(end - start)から、リクエスト回数を計算する
      const requestTimes = Math.ceil((end - start) / rowsPerPage);
      const array = [...Array(requestTimes)];
      try {
        const results = await Promise.all(
          array.map((_, index) =>
            dispatch(
              api.endpoints.getList.initiate(
                {
                  offset: start + index * rowsPerPage,
                  limit: rowsPerPage,
                },
                {
                  forceRefetch,
                }
              )
            )
          )
        );
        const listData = results.flatMap(
          ({ data }) => data?.listData ?? []
        );
        setData(listData)
      } catch (e) {
        console.log(e);
      } finally {
        setIsLoading(false);
      }
    },
    [dispatch]
  );

コードをできるだけ単調にした。

また、ポーリング用のfetch関数と、次のページロード用のfetch関数に分けていたものを統一の関数で扱えるようにした。

まだまだ不足点はあるが一旦はこれで。

まとめ

無限スクロールにポーリングを実装するというのは、not good。

あまりに時間をかけすぎて何をしているのかわからなくなっていった。

立ち止まって考える。

Discussion