🎿

Next.js✖️Firebase✖️useSWRInfiniteを使ったページネーション

2022/11/22に公開

記事について

前回に引き続き、今回はFirebaseswrを使ったページネーション機能を実装していきます。
ちょっと苦戦した箇所もあったので、共有します。

Firebase

FirebaseでのページネーションはFirebase側で用意されているlimitstartAt,startAfterもしくはlimitToLastendAtendBeforeを使うのが基本になるかと思います。

1ページ目
const getArticles = async () => {
  return await getDocs(query(collection(db, "articles"), limit(3)));
};
次へ進む
import { QueryDocumentSnapshot, DocumentData } from "firebase/firestore";

const getArticlesNext = async () => {
  //前回取得した最後の行のデータ
  //snapshotのまま代入する
  const previousLastData = previousSnapshot.docs[snapshot.docs.length - 1]
  
  return await getDocs(
    query(
      collection(db, "articles"),
      startAfter(previousLastData), 
      limit(3)
      )
    );
};
前へ戻る
import { QueryDocumentSnapshot, DocumentData } from "firebase/firestore";

const getArticlesBefore = async () => {
  //前回取得した最初の行のデータ
  const previousFirstData = previousSnapshot.docs[0]
  
  return await getDocs(
    query(
      collection(db, "articles"),
      endBefore(previousFirstData),
      limitToLast(3)
      )
    );
};

上記が、Firebase使用時のページネーションの基本となります。
しかし、今回はswrを使います。
違いは、コードがキレイ、前へ戻るを考慮しなくていい(SWRのキャッシュがあるため)ところです。

実装

早速、完成したページとコードをお見せします。

const getArticlesLimit = async (key, lim, nextCursor) => {
  const ref = collection(db, key);
  const q = nextCursor
    ? query(ref, startAfter(nextCursor), limit(lim))
    : query(ref, limit(lim));
  const querySnapshot = await getDocs(q);
  return querySnapshot;
};

const fetcher = async (key, pageIndex, limit, nextCucursor) => {
  // カーソル無し → 初期ページ
  // カーソル有り → 2ページ目以降
  const snapshot = nextCucursor
    ? await getArticlesLimit(key, limit, nextCucursor)
    : await getArticlesLimit(key, limit);

  const articles = snapshot.docs.map((d) => {
    return { id: d.id, document: d.data() };
  });
  // 次のカーソルを返す
  const cursor = snapshot.docs[snapshot.docs.length - 1];
  return { articles, cursor };
};

export const Page = () => {
  const [limit, setLimit] = useState(2);

  // 引数 previousPageData は fetcher の戻り値 (今回だと { articles, cursor })
  const getKey = (pageIndex, previousPageData) => {
    if (previousPageData && !previousPageData.articles.length) return null; // 最後に到達した
    if (pageIndex === 0) return ["articles", pageIndex, limit]; //最初のキー
    return ["articles", pageIndex, limit, previousPageData.cursor]; //次からのキー;
  };
  const { data, size, setSize } = useSWRInfinite(getKey, fetcher);

  if (!data) return <div>Loading...</div>;

  return (
    <div className="min-h-screen bg-gray-200 p-20 flex justify-center">
      <div>
        <div className="p-20 rounded-t-md bg-white space-y-4 w-[500px] h-[500px]">
          {data[size - 1]?.articles.map((article) => {
            return (
              <div
                key={article.id}
                className="bg-white px-4 border-2 rounded-md"
              >
                <p className="text-lg font-semibold">
                  {article.document.title}
                </p>
              </div>
            );
          })}
        </div>
        <div className="flex w-full flex-col bg-white rounded-b-md border-t">
          <div className="p-4 flex items-center justify-center">
            <button
              className="flex items-center justify-center border-gray-200 px-4 py-2 rounded-md border hover:border-blue-400"
              onClick={() => {
                setSize(size - 1);
              }}
            >
              戻る
            </button>
            <button
              className="flex items-center justify-center border-gray-200 px-4 py-2 rounded-md border hover:border-blue-400"
              onClick={() => {
                setSize(size + 1);
              }}
            >
              次へ
            </button>
          </div>
        </div>
      </div>
    </div>
  );
};

export default Page;

dataは、複数のAPIレスポンスの配列になります。

公式より引用
// `data` はこのようになります
[
  [
    { name: 'Alice', ... },
    { name: 'Bob', ... },
    { name: 'Cathy', ... },
    ...
  ],
  [
    { name: 'John', ... },
    { name: 'Paul', ... },
    { name: 'George', ... },
    ...
  ],
  ...
]

おまけ

Firebaseの公式Youtubeの中でも推奨されているlimitだけを使ったページネーション
1ページ目limit(3)、2ページ目limit(6)、3ページ目limit(9)のように、ページが進むごとに、limitの上限を増やしいく方法です。

メリット

圧倒的にシンプル。
余分なプロパティは一切いらず、データの更新・削除が起こっても、上から順に取得するだけなので問題ない。

デメリット

ページが進むごとに重複フェッチが発生する。
データ数100とし、各ページ25ずつ表示したい場合
1ページ目 取得数25
2ページ目 取得数50
3ページ目 取得数75
4ページ目 取得数100
4ページ分の取得数、合計250

import { useState } from "react";
import useSWR from "swr";
import { getDocs, collection, query, limit } from "firebase/firestore";
import { firestore } from "src/libs/firestore";

const fetcher = async (key: string, lim: number) => {
  const ref = collection(firestore, key);
  const q = query(ref, limit(lim));
  const snapshot = await getDocs(q);
  return snapshot.docs.map((d) => {
    return { id: d.id, document: d.data() };
  });
};

export const Page = () => {
  const [limit, setLimit] = useState(3);
  const [page, setPage] = useState(1);
  const { data } = useSWR(["articles", limit * page], fetcher);

  if (!data) return <div>Loading...</div>;

  const start = limit * (page - 1);
  const end = limit * page - 1;

  const articles = data.slice(start, end);

  return (
    <div className="min-h-screen bg-gray-200 p-20 flex justify-center">
      <div>
        <div className="p-20 rounded-t-md bg-white space-y-4 w-[500px] h-[500px]">
          {articles.map((article) => {
            return (
              <div
                key={article.id}
                className="bg-white px-4 border-2 rounded-md"
              >
                <p className="text-lg font-semibold">
                  {article.document.title}
                </p>
              </div>
            );
          })}
        </div>
        <div className="flex w-full flex-col bg-white rounded-b-md border-t">
          <div className="p-4 flex items-center justify-center">
            <button
              className="flex items-center justify-center border-gray-200 px-4 py-2 rounded-md border hover:border-blue-400"
              onClick={() => {
                setPage(page - 1);
              }}
            >
              戻る
            </button>
            <button
              className="flex items-center justify-center border-gray-200 px-4 py-2 rounded-md border hover:border-blue-400"
              onClick={() => {
                setPage(page + 1);
              }}
            >
              次へ
            </button>
          </div>
        </div>
      </div>
    </div>
  );
};

export default Page;

まとめ

いかがだったでしょうか?

違った点や、「ここ、こうすればもっと良くなるよ」などの意見がありましたら、コメントいただけると幸いです。
これからも、こういった技術ブログ的な記事を増やしていこうと思います。

Discussion