🐈

[Next.js]firestoreでgetServerSidePropsを使った時の無限ローディングなページネーションの実装

2022/02/13に公開

※この方法は無限ローディングタイプのページネーションにしか使えません。
通常のページネーションを firestore で行う場合は id をユニークな連番にするなどの工夫が必要なようです。

実装したコードは実装例にあります。

モチベーション

firestore で無限ローディングを使用したページネーションを作る機会があった。
getServerSideProps 側でデータを受信する場合、クライアントではその分を差し引いたデータを読み込ませたかったが、その方法について記述する良いサンプルが見つからず、自分なりに実装してみた。

firestore はv9を使用している。
firestore のページネーションはやりにくいという認識がある。
SQL と違って limit のみで実装が出来ないからだ。
offSet()もあるが、オフセットで取得する場合、オフセット指定より前のデータ分も課金対象となる。

クエリカーソルについて

SQL の場合

SQL に慣れていると例えば更新日時を降順で並び替えてその 20 番目から取得する、といった事が可能だからこれを使ってページネーションを実装する。

  -- idを降順で並び替えたデータの11番目から最大10件のデータを取得
  SELECT id, name FROM 'hoge' ORDER BY id DESC LIMIT 11, 10;

Firestore の場合

firestore では代わりにstartAt()もしくはstartAfter()というクエリカーソルを使用して開始地点を設定させる。

const ref = collection(DB, "posts");
// idを降順で並び替えて、最後のデータのidを格納
const q = query(ref, orderBy("id", "desc"), startAfter(lastId), limit(10));

上記の例では、startAfter()に入れた id より次のデータを最大 10 件取得している。

ちなみに、query に入れるQueryConstraintは配列なので、こんな事もできる模様。個人的にはソースコードの見通しがよくなるので好んで使用している。

// 配列に格納して、
const querys = [
  orderBy("updatedAt", "desc"),
  startAfter(lastUpdatedAt),
  limit(10),
];
// 展開して使用する
const qeryRef = query(ref, ...querys);

それぞれの違い

startAt()は特定の値を含むデータから取得。
startAfter()は特定の値より次のデータから取得。

クエリカーソルでは並び替えた順番で指定ができない。
あくまで、orderBy()で並び替えた値の特定値より上か下、のような形になる。

実装例

サーバー側

このクエリカーソルの特性を踏まえてgetServerSidePropsを跨いだ場合のページネーションを実装してみた。
サーバー側で 10 件のデータを先に取得させてクライアント側に渡している。

// サーバー側
export const getServerSideProps: GetServerSideProps = async (context) => {
  try {
    const ref = collection(DB, "posts");
    const querys = [orderBy("updatedAt", "desc"), limit(10)];
    const result = await getDocs(query(ref, ...querys));
    const data = result.data();
    return {
      props: {
        defaultPosts: data,
      },
    };
  } catch (error) {
    return {
      props: {
        error: error,
      },
    };
  }
};

クライアント側

クライアント側で次のページネーションをする場合、10 件を含まないデータがほしい。
これを実現する為に汎用性の高い日付をミリ秒の Timestamp を使用して解決することにした。

const ref = useRef(null);
const pageSize = 10;
const [posts, setPosts] = useState([...defaultPosts]);
const [isLoading, setIsLoading] = useState(false);

// サーバーから受け渡された10件より下回る場合はローディング終了
const [isCompleted, setIsCompleted] = useState(pageSize > defaultPosts.length);
// intersection observerを使ってトリガーとなるタグを監視している
const isBottomVisible = useIntersectionObserver(ref, { threshold: 0 }, false);
// 最後のデータの更新日時(ミリ秒のnumber)
const lastUpdatedAt = posts[posts.length - 1].updatedAt;

useEffect(() => {
  if (!isCompleted && !isLoading && isBottomVisible) {
    setIsLoading(true);

    // API読込
    const colRef = collection(DB, collectionPath);
    const querys = [
      // order by で更新日時を降順で指定し
      orderBy("updatedAt", "desc"),
      // start after で最後のデータの更新日時を指定
      startAfter(lastUpdatedAt),
      // 最大10件までのデータ取得
      limit(pageSize),
    ];
    const qeryRef = query(colRef, ...querys);

    (async () => {
      try {
        const result = await getDocs(qeryRef);
        const copy = [...posts];
        result.docs.map((snap) => {
          const data = snap.data();
          copy.push({
            id: snap.id,
            ...data,
          });
        });
        setPosts(copy);

        // 取得データが指定最大数を下回ったらローディング終了
        const completed = result.docs.length < pageSize;
        setIsCompleted(completed);

        setIsLoading(false);
      } catch (error) {
        console.error(error);
        setIsLoading(false);
      }
    })();
  }
}, [isBottomVisible]);

さいごに

firestore の仕様上、orderBy() で指定してないカラムを startAfter() で指定できない。
startAt()の場合も同様。

ミリ秒のタイムスタンプとはいえ、被る可能性もあるので完全なユニークな値ではありません。
そのため、startAfter()を使用すると同じ値のデータがあると表示されなくなる可能性があります。更新頻度が高いアプリだと厳しいかもしれません。

Discussion