はじめに
Firestoreで行うリアルタイム更新 + 無限スクロールの実装第二弾です。
Typescript + Reactで作っていきます。
私が考えるに、これには大きく分けて2つの方法があります。
- コスト高めだけど実装がシンプル
- コストを最小限にできるし、いろいろ応用が効くけど実装が大変
今回は、この中でも、"コストを最小限にできるし、いろいろ応用が効くけど実装が大変"な方を説明していきたいと思います。
実装方法
こちらの方法を一言でいうと、"取得とリアルタイム監視を分けて処理する"です。
まず、前回の方法は、無限スクロールの部分だけ考えればよかったのですが、こちらは、そもそもドキュメント作成部分で工夫が必要です。
作成するドキュメントに以下の3つのフィールドを最初から設定します。
フィールド名 | 意味 |
---|---|
createdAt | 作成日時 |
updatedAt | 更新日時 |
deletedAt | 削除日時 |
ドキュメントを追加するときは、createdAt、updatedAtに現在時刻を入れ、deletedAtにnullを入れます。 ドキュメントを変更するときは、updatedAtに現在時間を入れます。 ドキュメントを削除するときは、updatedAtとdeletedAtに現在時刻を入れます。実際の削除は行いません。
これをドキュメント作成時に実施することで今回の無限スクロールが実施できるようになります。
取得部分
では改めて無限スクロールを実装していきます。
まず行うのは単純に一定数のデータを取得する部分です。
import { useState, useEffect, useCallback } from 'react';
import { getDocs, limit, startAfter, query } from 'firebase/firestore';
import type { Query, DocumentData } from 'firebase/firestore';
export const useCollectionInfinity = <T = DocumentData>({
q,
limit: limitNumber = 20,
deps,
}: {
q: Query<T>;
limit: number;
deps?: any[];
}) => {
const [items, setItems] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [lastVisible, setLastVisible] = useState<unknown>();
const initialize = useCallback(() => {
setLoading(true);
setHasMore(false);
setLoadingMore(false);
setLastVisible(undefined);
getDocs(query(q, where('deletedAt', '==', null), limit(limitNumber + 1)))
.then((snapshot) => {
setItems(snapshot.docs.map((doc) => doc.data()).slice(0, limitNumber)));
if (snapshot.docs.length - 2 => 0) {
setLastVisible(snapshot.docs[snapshot.docs.length - 2]);
}
setHasMore(limitNumber + 1 === snapshot.size);
})
.catch((error) => {
console.error(error);
})
.finally(() => {
setLoading(false);
});
}, [limitNumber, q]);
useEffect(() => {
initialize();
}, deps ?? []);
return { items, loading, hasMore, loadingMore, loadMore, initialize };
};
基本はgetDocs
でlimit
を固定にしてデータを取っているだけです。
ただ、この後、さらに追加読み込みするかどうかを判断するためにhasMore
という状態を追加しています。
あとは、追加読み込みするときに、どこからさらにデータをとってくるかを残しておかないといけないので、取得したデータの最後のドキュメントをlastVisible
に保存しています。
20件+1をlimit
に指定することで、さらに次のデータがあるかどうかをわかるようにしています。
追加読み込み部分
ではこれに、さらに追加追加読み込みの部分を作っていきます。
import { useState, useEffect, useCallback } from 'react';
import { getDocs, limit, startAfter, query } from 'firebase/firestore';
import type { Query, DocumentData } from 'firebase/firestore';
export const useCollectionInfinity = <T = DocumentData>({
q,
limit: limitNumber = 20,
deps,
}: {
q: Query<T>;
limit: number;
deps?: any[];
}) => {
const [items, setItems] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [lastVisible, setLastVisible] = useState<unknown>();
// initilizeは省略
+ const loadMore = useCallback(() => {
+ if (loadingMore) return;
+ setLoadingMore(true);
+ getDocs(query(q, where('deletedAt', '==', null), startAfter(lastVisible), limit(limitNumber + 1)))
+ .then((snapshot) => {
+ const moreItems = snapshot.docs.map((doc) => doc.data()).slice(0, limitNumber);
+ setItems([...items, ...moreItems]);
+ if (snapshot.docs.length - 2 >= 0) {
+ setLastVisible(snapshot.docs[snapshot.docs.length - 2]);
+ }
+ setHasMore(limitNumber === snapshot.size);
+ setLoadingMore(false);
+ })
+ .catch((error) => {
+ console.error(error);
+ });
+ }, [loadingMore, lastVisible, items, q, limitNumber]);
return { items, loading, hasMore, loadingMore, loadMore, initialize };
};
追加で取得部分は、最初に取得とほとんど変わらないコードになります。startAfter
を追加することで読み込み始めを変えているだけです。
startAfter
に設定している変数はドキュメント自体を入れています。クエリーで指定したフィールドの値を入れることもできるのですが、汎用的に使えるようにここはドキュメントを入れています。SDK側でうまくフィールドを設定してくれる仕組みがあるのだと思います。
リアルタイム更新部分
最後にリアルタイム更新の部分を作ります。
// initilizeとloadMoreを省略
+ const watch = () => {
+ return onSnapshot(
+ query(
+ q,
+ where('updatedAt', '>=', Timestamp.now()),
+ limit(500)
+ ),
+ (snapshot) => {
+ snapshot.docChanges().forEach((change) => {
+ // 変更された結果だけほしいので削除は対象外
+ if (change.type === 'added' || change.type === 'modified') {
+ // 変更対象は一旦削除する
+ const _items = items.filter((m) => m.id !== change.doc.id)
+ // 削除されたときは削除された状態で終わる
+ if (change.doc.data().deletedAt) {
+ setItems(_items);
+ return;
+ }
+ // 追加して並び変え
+ setItems([..._items, change.doc.data()].sort((a, b) => {
+ return a.createdAt < b.createdAt ? 1 : -1;
+ }));
+ }
+ });
+ }
+ )
+ }
useEffect(() => {
initialize();
+ return watch();
}, deps ?? []);
何をやっているかというと、updatedAt
が現在の時刻より後のデータを見続けるというonSnapshot
を実行しているだけです。
このクエリーで、このドキュメントにおける全データの更新したタイミングが把握できるというわけです。
さらにここでやっとdeletedAt
の意味が出てきます。普通に削除してしまうと、updatedAtを更新してももちろん監視対象にならないので、このようにdeletedAtのようなフィールドを用意して、 消さないようにする必要があります。
あとlimit(500)
の部分ですが、ずっと動かしているとメモリ上に更新したデータがずっと溜まっていくことになるので、入れている感じです。500にしているのは、docChangesで一気に送られてくる数の上限が基本500だからです。
考察
前回の方法は、追加読み込みが行われるたびにonSnapshot
をし直す仕組みでした。
そのため、追加読み込みのたびにその数分の読み込みが走ってしまうので、最初に20、追加読み込みで40、さらに行うと60で、2回の追加読み込みで合計120ドキュメントの読み込みを行うことになります。
一方で今回の方法は、onSnapshot
は一回実行してから変更がありません。なので読み込みのコストは発生しないことになります。
2回の追加読み込みは合計60ドキュメントの読み込みで終わるので、だいぶ抑えられているのがわかります。
コスト面ではだいぶメリットのある方法であることが伝わったと思います。
さらに、このドキュメント全体の更新データをみることは、ドキュメント全体の更新タイミングを知ることができるため、例えば集計のcountをここでやってしまえば、合計数もリアルタイムで取得できたり、他の用途でも使えたりして便利です。
追加、更新、削除の際に工夫が必要なので、少しめんどうではありますが、それなりのメリットもある方法なので、前回の方法だけでなく、こちらも採用していった方がいいかなと思います。
終わりに
今回書いた内容は、以下の本でReactではなくて純粋なJavascriptで書き直して載せています。
これだけでなく、Firebaseを使ってて最初に悩みそうな、設計、実装、運用のトピックをいろいろまとめた本になります。
個人開発で行き詰まってる人や、Firebase使い出してちょっと困ったことが出てきそうな人はぜひ見てみてください!