Firestoreでリアルタイム更新＋無限スクロールの実装方法、２つのパターン:その１

# はじめに

Firestoreの１番の魅力と言っていい、リアルタイム更新機能ですが、それと無限スクロールの組み合わせって、かなりありそうなのに、意外と悩むところです。

弊社自社サービスのバーチャルオフィスRemottyは、チャットによるコミュニケーションを行うため、この実装はいろんなところでやっています。

そこで実装方法は、２つに分かれると思っていまして、それぞれのパターンで実装方法をまとめていきます。

２つのパターンをまとめると以下のような感じ

- コスト高めだけど実装がシンプル
- [コストを最小限にできるし、いろいろ応用が効くけど実装が大変](https://qiita.com/kontikun/items/94e30c59d4694beb4b4a)

今回はこの中でもシンプルなる１つ目の方法の実装を行います。


## 実装方法

この方法は、Firestoreのリアルタイム監視の仕組みにすごくマッチしているので、実装自体はシンプルです。

ざっくり実装方法をいうと、"limitを増やしてロードしていく"です！

まず、Firestoreのリアルタイム更新の関数であるonSnapshotの使い方をおさらいします。


```ts
import { collection, query, where, onSnapshot } from "firebase/firestore";

const q = query(collection(db, "cities"), where("state", "==", "CA"));
const unsubscribe = onSnapshot(q, (querySnapshot) => {
  const cities = [];
  querySnapshot.forEach((doc) => {
      cities.push(doc.data().name);
  });
  console.log("Current cities in CA: ", cities.join(", "));
});
```

公式そのままです。
v9の書き方で書いています。
これだけでリアルタイムに更新してくれるFirestoreは便利ですね。

onSnapshotの第２引数がcallbackの関数になっていて、ここが指定したqueryの結果内で変更があるたびに実行されるような形になります。

callbackの引数でくるquerySnapshotですが、この書き方だと対象のドキュメントが更新されるたびにqueryの結果すべてが返ってきます。
ここを`docChanges`に変えると、更新されたドキュメントだけを取得できます。
`docChanges`は、typeというプロパティを持っていて、そこをみると、追加されたもの、変更されたもの、削除されたものを把握できるようになっています。

ただ、今回の方法はそこも考える必要はありません。

では、本題の無限スクロールの処理をReactのカスタムフックとして作ってみます。

今回のカスタムフックは、以下のstateと関数があれば十分かなと思います。

- state
    - items 実際のデータが入ってくるところ
    - hasMore さらに追加するデータが存在するか？
    - loading データ取得するまでの状態
- 関数
    - getMore 追加取得

では実際のコードです。
まずはカスタムフックのコードです。

```ts:useCollectionInfinity.ts
import { useState, useEffect, useCallback } from "react";
import { limit, query as _query, onSnapshot } from "firebase/firestore";
import type { Query, DocumentData, Unsubscribe } from "firebase/firestore";

export const useCollectionInfinity = <T = DocumentData>({
  query,
  pageSize = 20,
}: {
  query: Query<T>;
  pageSize?: number;
}) => {
  const [items, setItems] = useState<T[]>([]);
  const [loading, setLoading] = useState(true);
  const [hasMore, setHasMore] = useState(false);
  const [currentLimit, setCurrentLimit] = useState(pageSize);

  let unsubscribe: Unsubscribe | undefined = undefined;

  const initialize = useCallback(() => {
    setLoading(true);
    if (unsubscribe) {
      unsubscribe();
      unsubscribe = undefined;
    }
    unsubscribe = onSnapshot(
      _query(query, limit(currentLimit + 1)),
      (snapshot) => {
        setItems(snapshot.docs.map((doc) => doc.data()).slice(0, currentLimit));
        setHasMore(snapshot.size > currentLimit);
        setLoading(false);
      }
    );
  }, [currentLimit]);

  const getMore = useCallback(() => {
    setCurrentLimit(currentLimit + pageSize);
  }, [loading]);

  useEffect(() => {
    initialize();
    return () => {
      unsubscribe && unsubscribe();
    };
  }, [currentLimit]);

  return {
    items,
    hasMore,
    loading,
    getMore,
  };
};
```
今回の説明で余分になりそうなqueryが変化する場合や、errorが出た時の処置は書いていません。

`currentLimit`がgetMoreによって変更され、onSnapshotで監視する対象のデータが増えていくような形になっているのがわかるかなと思います。
また、limitを+1していますが、これは`hasMore`、これ以上にデータがあるかないかを判断するためにやっています。

使う側は以下のような感じです。
```tsx
import {
  DocumentData,
  QueryDocumentSnapshot,
  SnapshotOptions,
  FirestoreDataConverter,
  PartialWithFieldValue,
  collection,
  getFirestore,
  orderBy,
  query,
} from "firebase/firestore";
import { useCollectionInfinity } from "../hooks/useCollectionInfinity";
import InfiniteScroll from "react-infinite-scroller";

const getConverter = <T extends DocumentData>(): FirestoreDataConverter<WithId<T>> => ({
  toFirestore: (data: PartialWithFieldValue<WithId<T>>): DocumentData => {
    const { id, ...dataWithoutId } = data;
    return dataWithoutId;
  },
  fromFirestore: (snapshot: QueryDocumentSnapshot<T>, options: SnapshotOptions): WithId<T> => {
    return { id: snapshot.id, ...snapshot.data(options) };
  },
});

const messageConverter = getConverter<{ message: string }>();

export const Chat = () => {
  const { items, hasMore, getMore } = useCollectionInfinity({
    query: query(
      collection(getFirestore(), "messages"),
      orderBy("createdAt", "desc")
    ).withConverter(messageConverter),
    pageSize: 10,
  });

  return (
    <div>
      <div>チャットルーム</div>
      <InfiniteScroll
        loadMore={getMore}
        hasMore={hasMore}
        loader={<div key={0}>Loading ...</div>}
      >
        {items.map((item) => {
          return (
            <div key={item.id}>
              {item.message}
            </div>
          );
        })}
      </InfiniteScroll>
    </div>
  );
```

では、これがFirestoreとのやりとりやstateの状態がどうなっていくかを説明したと思います。

# Firestoreのデータの変化とステートの変化

## データ取得時

例えば、この`messages`コレクションの中にドキュメントがあったときは、`limit`で設定した上限で取得が行われるので、`createdAt`が最新のものから順に並んだ状態でonSnapshotのコールバック関数の第一引数にデータが入ってきます。
20件が設定されていれば、 `snapshot.docs`は20件になります。
Firestoreからは20件のデータを取得したことになります。
ここまではシンプルですね。

## データが追加されたとき

データが追加される時は、追加されたドキュメントの`createdAt`の値によって変わってきます。

### 今までのドキュメントより新しい`createdAt`のドキュメントが増えたとき

この場合は、1件追加され、1件削除された形であたらめて20件の`snapshot.docs`が返ってきます。

これは、`snapshot.docs`がquery結果に沿った形で維持されるためで、最新の20件を取るために、一番古かったドキュメントが削除され、新しいドキュメント1件が追加される形となったためです。

`snapshot.docChanges()`で変化したものだけを見るようにすると、想定通り1件追加と一件削除でデータが返ってきます。

Firestoreのサーバーからのデータ取得はどうなるかというと、これも同じく新しいドキュメントが追加されて、古いドキュメントのidが削除対象として送られてきました[^1]。

ちなみにこの結果から、onSnapshotは今まで取得したデータをキャッシュしながら、変化点のみを取得して、同じ形に加工するところまでをやってくれていることになります。
この仕組みを考えると下手に配列をいじるよりは、素直にstateにそのままセットするのが形としてシンプルになります。

### 今までのドキュメントの期間内の`createdAt`のドキュメントが増えたとき

この場合も実際には同じです。
配列の間に挿入されるだけで、実態としては、１ドキュメント追加と1ドキュメント削除が行われます。

`snapshot.docChanges()`も同じで、Firestoreからのデータも同じです。

### limit外のデータが増えた時

この場合は、もうお気づきかと思いますが、何も変化ありません。
queryで指定した状態のデータを返す仕組みなので範囲外のドキュメントは`onSnapshot`は何もしないです。
これにより無駄なドキュメントを取得しなくて済むようになっています。

## データが変更されたとき

これも今までの流れでわかると思いますが、変更されたドキュメントがqueryの範囲内であれば、配列の中の対象のドキュメントが変更され、最新の状態のドキュメントの配列が返ってきます。

## データが削除されたとき

これもそんなに難しい話はないです。
queryの状態を維持することになるので、query外の削除は何も起きません。
query内のドキュメントが削除された場合は、そのドキュメントは削除されます。
ただ、limitで設定した以上のドキュメントがコレクションに存在していれば、その分が追加されることになります！
ほんと至れり尽くせりの仕組みです。

## `getMore`が動いたとき(無限ロードが発動したとき)

ここが今回の動きで一番気になる部分かと思います。
チャット画面で、最新の状態は見えているけど過去に遡りたいときに、スクロールして追加データを取る動きの部分です。
コンポーネント側では`getMore`が実行されるだけです。
`onSnapshot`では、queryの中のlimitの部分が20件から40件に増える変化が起こります。

今までの話で照らし合わせると、queryの状態を維持するという`onSnapshot`ですが、そのqueryが変わればそのまま`onSnapshot`し続けることができなくなります。
なので、一旦監視状態をキャンセルして、新たに`onSnapshot`し直すということが必要になります。

結果として、`onSnapshot`としてはやり直しになるので、スタートのひとまずlimit分取得するが動いて、その後は同じ流れという感じになります。

お気づきかと思いますが、この`getMore`が動いた時は40件取り直しになるので、結果として20件取った後にさらに重複したデータ込みで40件取る、という動きになります。
もちろんさらに20件となると、 20+40+60となりどんどんコストが増えるという感じです。

ここがどうしてもコストが無駄にかかってしまう感じになるのがこの実装のデメリットの部分かなと思います。

ちなみに、Reactとしては、stateの変化は20件増えただけに見えるので、追加ロードしたように見えるようになっています。

# 終わりに

簡単なパターンのリアルタイム更新＋無限スクロールの実装いかがだったでしょうか？
言われてみれば簡単ですが、意外と思いつかない方法だと思います。

ただ、実装がシンプルですし、ほとんどの場合はそんなにコストも目を瞑ることができる方法だと思いますので、基本的にはこの方法を採用すればいいかなと思います。

パターンその２はだいぶ説明が長くなるので、近々別の記事として投稿します！


[^1]:完全に余談になりますが、onSnapshotはかなり優秀で、同じqueryであれば２回呼び出していたとしても、Firestoreとの通信は１つのonSnapshotとして動くようになっていて、Firestoreの料金も1つ分としてカウントされるので非常に効率がいい形になっています。一方でgetで取得する場合は、都度かかります。