🧯

今の時代の Firestore の状態管理を模索する

2022/08/02に公開

Hooks の登場、GraphQL や SWR などの Query 系のライブラリの台頭によって、データ取得が宣言的になってきました。

以前は、一つのデータを色んなコンポーネントで使うときに、バケツリレーを避けたいとかの理由で、Redux やらでサーバーのデータをフロントに保持して単方向に流す状態管理をしていました。

SWR などキャッシュ機構をもつライブラリが普及してきて、同じリソースの再取得はキャッシュから取得できて無駄にならないし、 「必要なところで必要なデータを取得してるこっちの方がスマートなんじゃない?」 とコンポーネントごとに都度データをリクエストする設計(「コロケーション」という原則に則る)が採用されてきています。

状態管理の変遷は以下の記事がわかりやすかったです。

https://eh-career.com/engineerhub/entry/2022/01/13/090000

また、コロケーションという言葉は以下の記事で知りました。

https://zenn.dev/shimpeiws/articles/afcc43990d13c0

この流れを汲んだ上で Firestore の状態管理について考えます。

理想的な実装のイメージ

Hooks 時代の宣言的なフロントエンド開発の理想的なイメージを共有しておきます(あくまで個人的な)。

データバインディングの転換

Hooks のパラダイムを理解するために jQuery から React, Vue へのパラダイムの転換を振り返ります。

  • 「描画を変える命令が必要」から
  • 「状態(変数の値)が変われば自動的に描画が変わる」へ

実装の方針として

  1. サーバーからデータを命令的に取得
  2. フロントで保持する
  3. コンポーネントが必要なデータを整理
  4. データを元に描画を記述

という流れで、一旦フロント側に保持したローカルデータにバインディングするよ、という開発でした。

その後、Hooks が台頭してからのパラダイムは、

  • 「データの取得に命令が必要」から
  • 「リソースに対して直接バインディング」へ

Hooks のおかげで useXXX にデータに関するロジックが隠蔽されて、コンポーネントは変化する宣言的なデータを受け取るだけで良くなりました。 react-use などで提供されている「これが Hooks の凄さだ」的な例でよく用いられる useMouse なんかも、リスナーの登録などに関心を持たず、コンポーネントが必要なデータだけを受け取っています。

const { x, y } = useMouse();

これと同じノリでサーバーデータを以下のようにバインディングできます(SWRの例)。

const { data } = useSWR('/api/user', fetcher)

SWR はリソースの再取得も柔軟にコントロールできるため、静的ではない動的なデータ取得ができるというのも特筆すべき点かと思います。

前述の React, Vue の「3. コンポーネントが必要なデータを整理」に関しても、GraphQL でスキーマを指定して必要なデータだけを取り出せるように、サーバーから直でコンポーネントが使いやすいデータを受け取る流れが来ているのかなと感じます。

サーバーへのリクエスト節約

冒頭で触れたコロケーションに則って、コンポーネントごとに必要なデータをバインディングしたいところですが、何も考えずにバインディングしていると、その都度サーバーのリクエストが発生してしまいます。

SWR や GraphQL の Apollo Client なんかは、キャッシュ機構が備わっていて、あまり意識することなくデータの取得を記述できます。

理想的な実装の条件

これまでの話から、条件は以下のようになりそうです。

  • それを取得するための購読・解除などコンポーネント側で意識しない(追加読み込みなどは別)
  • 描画されている限り、できるだけ変更をリアルタイムに反映する
  • 同じデータは何度呼び出しても無駄なリクエストが発生しない

これらを踏まえて Firestore での実装をどうすればいいか、どういう仕様になっているかを考えながら模索します。

Firestore の前提

Firestore におけるデータの取得方法は主に getDoc() / getDocs()onSnapshot() の2種類です。上記のリアルタイムに反映するという条件から、 onSnapshot() をメインに使うことになります。

同クエリへの複数回リッスンの挙動

Firestore の onSnapshot() は同じ Document / Collection / Query であれば、複数回リッスンしても外部リクエスト自体は1回になります。

https://groups.google.com/g/firebase-talk/c/y9UZDTTtlCI/m/qoxNCvOVCAAJ

// <UserList>
onSnapshot(collection(db, 'users'), () => {})

// <PostList>
onSnapshot(collection(db, 'users'), () => {}) // ↑の結果が再利用される

1つでもリッスンしていれば、その上で登録や解除をいくらしても追加のリクエストは走りません。

ただ完璧に同じクエリである必要があります。

// <UserList>
onSnapshot(query(collection(db, 'users'), orderBy('createdAt', 'desc'), limit(10)), () => {})

// <PostList>
onSnapshot(query(collection(db, 'users'), orderBy('createdAt', 'desc'), limit(20)), () => {}) // 再利用されない

getDocs() / getDoc() はその都度リクエストされます。

Image from Gyazo

オフラインデータの利用

また、Firestore にはローカルにキャッシュを持つことでオフラインでもデータを利用できる機能があります。enableIndexedDbPersistence() を実行することで利用できます。

onSnapshot() はオンラインである限りサーバーからデータを取得するためリクエスト節約には利用できそうにないですが、 getDoc() / getDocs() あれば getDocsFromCache(), getDocsFromServer() など取得先を明示して取得できます。

@react-query/firebase ではその切り替えが明示的に利用できるようになっていました。

https://react-query-firebase.invertase.dev/firestore/querying-collections#cached--server-data

とはいえ、オフライン対応するだけで注意することがたくさん出てくるので、気軽には導入できないかとは思います。

ユースケースごとに実装方法を検討する

それでは実際にどういう実装が考えられるかユースケースごとに検討します。
僕自身が Vue の方をよく使うので Vue の Composition API で書きます。

基本形

onSnapshot() の結果をそのままバインドするパターンです。

react-firebase-hooksuseCollectionvueuseuseFirestore などで実装されているかたちです。

useFirestore の説明がわかりやすいので引用します。

Reactive Firestore binding. Making it straightforward to always keep your local data in sync with remotes databases.

利用例
const todos = useFirestore(collection(db, 'todos'))
実装の概略
export function useFirestore(docRef) {
  const data = ref([])
 
  const close = onSnapshot(docRef, (snapshot) => {
    data.value = snapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id }))
  })
  
  onUnmounted(() => {
    close()
  })
  
  return data
}

onSnapshot() のおかげで各コンポーネントで呼び出しても無駄なリクエストは走らないので、これだけで条件通りの実装ができてしまいました。

実際は、エラーハンドリングや読み込み中の状態も持つのが一般的かと思います。

クエリが動的な場合も対応する場合は、以下のようになります。

利用例
const postsLimit = ref(10)
const postsQuery = computed(() => query(collection(db, 'posts'), orderBy('createdAt', 'desc'), limit(postsLimit.value)))
const posts = useFirestore(postsQuery)
実装の概略
export function useFirestore(refOfDocRef) {
  const data = ref([])
  let close = () => {}
  
  watch(refOfDocRef, (docRef) => {
      close()
    close = onSnapshot(docRef, (snapshot) => {
      data.value = snapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id }))
    })
  }, { immediate: true })

  onUnmounted(() => {
    close()
  })
  
  return data
}

実はつい先日、 vueuse にこの動的なクエリを対応する PR を投げたらマージしてもらえたので v9.0.1 以降であれば useFirestore でも利用できるようになりました。

https://github.com/vueuse/vueuse/pull/2008

段階的データ

記事一覧の無限スクロールなどコンテンツを段階的に取得するケースです。コンテンツの追加や変更も受け付ける場合を考えます。

使用例
const { contents, loadMore } = useIncrementalData(
  query(collection(nuxtApp.$firestore, "posts"), orderBy("createdAt", "desc"))
);
実装の概略
const LIMIT = 25

export function useIncrementalData<T extends DocumentData>(
  queryVal: Query<T>
) {
  const contentsLimit = ref(LIMIT)
  const lastContentId = ref('')

  const contentsQuery = computed(() => query(queryVal, limit(contentsLimit.value)))
  const contents = useFirestore(contentsQuery, [])

  const loadMore = () => {
    const lastContent = contents.value.slice(-1)[0]
    lastContentId.value = lastContent ? lastContent.id : ''
  }

  watch(lastContentId, () => {
    contentsLimit.value += LIMIT
  })

  return {
    contents,
    loadMore,
  }
}

基本形の useFirestore() を使った応用で、クエリの limit() を2倍、3倍...と大きくしていくやり方です。

こちらも実際は、ローディングやエラーハンドリング、読み込み終わり判定の状態を渡すなどやるかと思います。

この手法のデメリットは毎回新しいクエリを張りなおしているので、リクエストのドキュメント取得数が 25 + 25 + 25 + ... ではなく 25 + 50 + 75 + ... となってしまう点です。

  • ドキュメントの総数が大したことない
  • 変更を検知したい

という場合に使えるもので、逆に変更されない(or変更を追うまでもない)データは次の「ストリームデータ」の例が良いかと思います。

ストリームデータ

ログなどリアルタイムで追加されていく、かつ内容の変更がないデータを読み込むパターンです。変更がないため、不用意にリスナーを貼る必要はありません。

  • onSnapshot() で新しいデータをリッスン
  • getDocs() で既存データを読み込み

使用例
const { logs, loadMore } = useStreamData(
  query(collection(nuxtApp.$firestore, "logs"), orderBy("createdAt", "desc"))
);
実装の概略
const LIMIT = 25

export function useStreamData<T extends DocumentData>(
  queryVal: Query<T>
) {
  const lastLogRef = ref<DocumentReference | null>(null)
  const rawLogs = ref([]) as Ref<T[]>

  const upcomingLog = useFirestore(query(queryVal, limit(1)), [])
  const upcomingLogId = computed(() => upcomingLog.value.length ? upcomingLog.value[0].id : '')
  const logs = computed(() => uniqBy(rawLogs.value, 'id')) // lodash の uniqBy を想定

  const loadQuery = async () => {
    const lastLogSnap = lastLogRef.value ? await getDoc(lastLogRef.value) : null
    const logQuery = lastLogSnap ? query(queryVal, startAfter(lastLogSnap), limit(LIMIT)) : query(queryVal, limit(LIMIT))
    const moreLogs = (await getDocs<T>(logQuery)).docs.map(doc => ({ ...doc.data(), id: doc.id, ref: doc.ref }) as T)
    rawLogs.value = rawLogs.value.concat(moreLogs)
  }

  watch(lastLogRef, () => {
    loadQuery()
  }, { immediate: true })

  watch(upcomingLogId, () => {
    rawLogs.value = upcomingLog.value.concat(rawLogs.value)
  })

  const loadMore = () => {
    const lastLog = rawLogs.value.slice(-1)[0]
    lastLogRef.value = lastLog && lastLog.ref ? lastLog.ref : null
  }

  return {
    logs,
    loadMore,
  }
}

新しいデータは limit(1) のクエリを useFirestore() でリッスンして、受け取ったら配列の先頭に追加します。

追加読み込みは startAfter() に最後尾のスナップショットを渡してデータを取得していきます。取得したデータは配列の末尾に追加します。

変更がないデータなので、 getDocsFromCache() を使って「キャッシュ優先」の取得にしてあげると、リクエスト数が節約できてなお良さそうです。

注意した方がいいのは、タブを放置して戻ってきたときなどにその間のデータが追加されていないケースがあったので、強い整合性を求められる場合は、前述の useIncrementalData() の方が良いかもしれません。

クライアントサイドジョイン

ドキュメント内に Reference があってその Reference をさらに取得したい場合です。

PostList.vue
<template>
  <PostListItem v-for="post in posts" :key="post.id" :user-ref="post.userRef" />
</template>

<script setup lang="ts">
// import など省略
const posts = useFirestore(collection(db, "posts"))
</script>
PostListItem.vue
<template>
  <div>{{ user }}</div>
</template>

<script setup lang="ts">
// defineProps など省略
// こちらの useFirestore は上述のものではなく、 vueuse のものです
const user = useFirestore(props.userRef)
</script>

アイテム単体のコンポーネントで Document Reference を useFirestore() すれば良いだけですね(アイテム単体のコンポーネントが必ず必要にはなりますが)。例によって onSnapshot() のおかげで同じユーザーが複数の記事を書いている場合でも、無駄なリクエストが走らず1ユーザーにつき1リクエストで済みます。

以前は vuexfire という Vuex Store に Firestore をバインドできるライブラリを使っていたのですが、Store にバインドした時点で Reference が展開される機能があって便利!とか思ってましたが、今考えると必要なところで必要なものを無駄なく取得できた方がスマートだと思ってしまいますね。。

結論

onSnapshot() は昔からあるメソッドなのですが、今の時代を見越して作られていたのかと言わんばかりのマッチ具合でした。

リスナーを貼りまくるの自体パフォーマンス的にどうなのという疑問があったり、UX を追求したり、柔軟にデータを横断したいときなどは結局 useFirestore() ではなく自前実装になったりするのですが、大まかな方向性として参考になれば幸いです。

Discussion