[Next.js]firestoreでgetServerSidePropsを使った時の無限ローディングなページネーションの実装
※この方法は無限ローディングタイプのページネーションにしか使えません。
通常のページネーションを 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