Next.js + Firebase Firestore で Pagination を実装する
Next.js で Firebase Firestore の Pagination を実装していきます。
Pagination の動きは下記で確認できます。
まず 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