🐕

Next.js + Firebase Firestore で Pagination を実装する

2023/12/27に公開

Next.js で Firebase Firestore の Pagination を実装していきます。

Pagination の動きは下記で確認できます。

https://mob-outputs.vercel.app/

まず Firestore の '制限' について

Firestore には limit の機能はありますが、 offset に相当する機能がありません。 そのため、下記のようなページ番号を指定するようなページネーションは実現できません。

一方でクエリの開始位置や終了位置の指定はできます。そのため、下記のような ひとつ前のページ と 次のページに遷移するようなページネーションであれば実現可能です。

実装方針

次のような Post のリストをページネーションしていくような実装を考えます。

interface Post {
  url: string;
  image: string;
  title: string;
  author: string;
  authorImage: string;
  date: string;
}

初回のフェッチ

まず 初回のデータを取得する関数を実装すると次のようになります。

async function fetch(): Promise<Post[]>  {
  onComplete: (posts: Post[]) => void;
}) {
  const db = getFirestore(firebaseApp);
  const col = collection(db, "posts");
  const querySnapshot = await getDocs(query(col, orderBy("date", "desc"), limit(NUM_OF_ITEMS)));
  const ret: Post[] = [];
  querySnapshot.forEach((doc) => {
    ret.push(doc.data() as Post);
  });
  return ret;
}

重要なのは下記のクエリ部分です。 date で降順にして、limit を指定しています。

query(col, orderBy("date", "desc"), limit(NUM_OF_ITEMS))

次のページをフェッチ

次のページをフェッチする関数を実装すると、次のような関数になります。

async function fetchNext({ option }: { cursor?: String }): Promise<Post[]> {
  const db = getFirestore(firebaseApp);
  const col = collection(db, "posts");
  const querySnapshot = await getDocs(
    query(
      col,
      orderBy("date", "desc"),
      startAfter(option.cursor),
      limit(NUM_OF_ITEMS)
    )
  );
  const ret: Post[] = [];
  querySnapshot.forEach((doc) => {
    ret.push(doc.data() as Post);
  });
  return ret;
}

先ほどと違うのは query 部分です。

query(
  col,
  orderBy("date", "desc"),
  startAfter(option.cursor),
  limit(NUM_OF_ITEMS)
)

date で降順にして、limit を指定してたあとに、 startAfter で cursor を指定したあとに limit をかけています。 startAfter は 指定した cursor の次のデータから取得するクエリです。これによって、次のページを取得することができます。

前のページをフェッチ

またこちらも関数を実装すると、次のようになります。

async function fetchNext({ option }: { cursor?: String }): Promise<Post[]> {
  const db = getFirestore(firebaseApp);
  const col = collection(db, "posts");
  const querySnapshot = await getDocs(
    query(
      col,
      orderBy("date", "desc"),
      endBefore(option.cursor),
      limitToLast(NUM_OF_ITEMS)
    )
  );
  const ret: Post[] = [];
  querySnapshot.forEach((doc) => {
    ret.push(doc.data() as Post);
  });
  return ret;
}

やはり違うのはクエリ部分です。

query(
  col,
  orderBy("date", "desc"),
  endBefore(option.cursor),
  limitToLast(NUM_OF_ITEMS)
)

endBefore が指定した cursor より前のデータを取得します。また limitToLast は指定したデータを末尾とした件数だけ取得します。

データ取得をいい感じにまとめる

初回のデータ取得、次のページの取得、前のページの取得 はほとんどクエリが違うだけなので、まとめていい感じのデータにします。

enum PagingMode {
  Prev,
  Next,
}

interface PagingOption {
  mode: PagingMode;
  cursor: string;
}

async function fetch({ option }: { option?: PagingOption }): Promise<Post[]> {
  const db = getFirestore(firebaseApp);
  const col = collection(db, "posts");
  let q;
  if (option && option.mode == PagingMode.Next) {
    q = query(
      col,
      orderBy("date", "desc"),
      startAfter(option.cursor),
      limit(NUM_OF_ITEMS)
    );
  } else if (option && option.mode == PagingMode.Prev) {
    q = query(
      col,
      orderBy("date", "desc"),
      endBefore(option.cursor),
      limitToLast(NUM_OF_ITEMS)
    );
  } else {
    q = query(col, orderBy("date", "desc"), limit(NUM_OF_ITEMS));
  }
  const querySnapshot = await getDocs(q);
  const ret: Post[] = [];
  querySnapshot.forEach((doc) => {
    ret.push(doc.data() as Post);
  });
  return ret;
}

Postリストを取得するカスタムフックを作る

もう少し綺麗にできそうですが、シュッと作ってみるとこのような実装になりました。

export function usePosts(): [Post[], number, number, onPrev, onNext] {
  const [page, setPage] = useState(1);
  const [lastPage, setLastPage] = useState(1);
  const [posts, setPosts] = useState<Post[]>([]);

  useEffect(() => {
    const fetch = async () => {
      const db = getFirestore(firebaseApp);
      const col = collection(db, "posts");
      const count = (await getCountFromServer(query(col))).data().count || 0;
      setLastPage(Math.floor(count / NUM_OF_ITEMS) + 1);
    };
    fetch();
  }, []);

  useEffect(() => {
    fetch({}).then((posts) => {
      setPosts(posts);
    });
  }, []);

  const onPrev = () => {
    if (page <= 1) return;
    const prevPage = page - 1;
    fetch({
      option: {
        mode: PagingMode.Prev,
        cursor: posts[0].date,
      },
    }).then((posts) => {
      setPosts(posts);
      setPage(prevPage);
    });
  };
  const onNext = () => {
    if (page >= lastPage) return;
    const nextPage = page + 1;
    fetch({
      option: {
        mode: PagingMode.Next,
        cursor: posts[posts.length - 1].date,
      },
    }).then((posts) => {
      setPosts(posts);
      setPage(nextPage);
    });
  };
  return [posts, page, lastPage, onPrev, onNext];
}

イメージ的にはこのようなコードになっています。

export function usePosts(): [Post[], number, number, onPrev, onNext] {
  ...

  useEffect(() => {
    const fetch = async () => {
      ここで Post の トータルの数を取得
    };
    fetch();
  }, []);

  useEffect(() => {
    初回データを取得して posts に設定する
  }, []);

  const onPrev = () => {
    if (page <= 1) return;
    前のページのデータを取得して posts に設定する
  };
  const onNext = () => {
    if (page >= lastPage) return;
    次のページのデータを取得して posts に設定する
  };
  return [posts, page, lastPage, onPrev, onNext];
}

ui 側はこのカスタムフックを使って、下記のような実装になっています。 (mui を使ってます)

export default function Home() {
  const [posts, page, lastPage, onPrev, onNext] = usePosts();

  return (
    <>
      ...
      <main ...>
        ... posts をもとに データ表示
        {posts.length > 0 && (
          <Box display="flex" justifyContent="center">
            <Button disabled={page == 1} onClick={() => onPrev()}>
              Prev
            </Button>
            ...
            <Button
              disabled={page >= lastPage}
              onClick={() => onNext()}
            >
              Next
            </Button>
          </Box>
        )}
      </main>
    </>
  );
}

page, lastPage をもとに それぞれの disabled を設定しています。

以上で完成です!

ついでにキャッシュを使うようにする

以上でしっかり動くのですが、都度都度 Firestore から fetch をするのは パフォーマンス上もお財布的にも良くないので (自分のサイトがそこまでみられることもないでしょうが )、一応オンメモリにデータをキャッシュできるようにしておきます。

下記が キャッシュかも完了したあとのコード全文です。

export function usePosts(): [Post[], number, number, onPrev, onNext] {
  const [page, setPage] = useState(1);
  const [lastPage, setLastPage] = useState(1);
  const [posts, setPosts] = useState<Post[]>([]);
  const [cache, setCache] = useState<{ [key: number]: Post[] }>({});

  useEffect(() => {
    const fetch = async () => {
      const db = getFirestore(firebaseApp);
      const col = collection(db, "posts");
      const count = (await getCountFromServer(query(col))).data().count || 0;
      setLastPage(Math.floor(count / NUM_OF_ITEMS) + 1);
    };
    fetch();
  }, []);

  useEffect(() => {
    fetch({}).then((posts) => {
      setPosts(posts);
      setCache({ [1]: posts });
    });
  }, []);

  const onPrev = () => {
    if (page <= 1) return;
    const prevPage = page - 1;
    if (cache[prevPage]) {
      setPosts(cache[prevPage]);
      setPage(prevPage);
    } else {
      fetch({
        option: {
          mode: PagingMode.Prev,
          cursor: posts[0].date,
        },
      }).then((posts) => {
        setPosts(posts);
        setCache((prev) => ({ ...prev, [prevPage]: posts }));
        setPage(prevPage);
      });
    }
  };
  const onNext = () => {
    if (page >= lastPage) return;
    const nextPage = page + 1;
    if (cache[nextPage]) {
      setPosts(cache[nextPage]);
      setPage(nextPage);
    } else {
      fetch({
        option: {
          mode: PagingMode.Next,
          cursor: posts[posts.length - 1].date,
        },
      }).then((posts) => {
        setPosts(posts);
        setCache((prev) => ({ ...prev, [nextPage]: posts }));
        setPage(nextPage);
      });
    }
  };
  return [posts, page, lastPage, onPrev, onNext];
}

完成!!!

Discussion