vue-composition-apiでFirestoreを扱うreact-firebase-hooks風のComposition Functionを作った

## 2020/09/04加筆修正

こちらのコメントを @kahirokunn さんから頂いたので、微修正を加えました！
Converterについては、まだ手元で動作確認していないので、いい感じの代案が作れたらConverter無しバージョンも作ってみたいと思います。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">細かい所とかだとwatchはimmediateオプションあるとか、query変更時に前のsubscribe解除してないとかありますが、使い勝手は良さそうですね！<br>Converter関数渡すなら、自分で取得したデータをcomputedで変換すればいいので、Converterいらないかなぁとも思いました</p>&mdash; { __typename: “kahirokunn” } (@kahirokunn) <a href="https://twitter.com/kahirokunn/status/1301717082116030466?ref_src=twsrc%5Etfw">September 4, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

普通にTwitterでシュッと意見を聞いてみただけなのに、Firestoreのリスナ解除していないことを見抜いてしまうあたり場数が違うなあと思いました（僕は手元で動かしてようやく気がついたのでｗ）。

## 概要

`composition-api`を使って`Firebase`の`Firestore`からデータ/ローディング/エラーなどを読み取ることができる`Composition Function`を作りました。

`useCollection.ts`と名付けており、`useCollectionData<T>`というメソッドを`export`しています。

ソースコードはこちらの`Gist`に上げていますが、本記事ではこの内容についてざっと解説していこうと思います。
https://gist.github.com/TeXmeijin/380e52febdae921f31afe382af1bb40f

## `useCollection`活用の全体像

まずはこの`Composition Function`はどういった仕様か、また、どのように`Component`で活用できるかを説明します。

```useCollection.ts
export const useCollectionData: useCollectionData = <T extends { id: string }> (
  getQuery,
  option
) => {
  const {
    snapshot, loading, error,
  } = useCollection<T>(getQuery, option)

  const values = computed(
    () => {
      return (snapshot.value
        ? snapshot.value.docs.map(doc => {
          return {id: doc.id, ...doc.data()}
        })
        : []) as T[]
    }
  )

  return {
    values, loading, error,
  }
}
```

※本家の`react-firebase-hooks`は一度きりの読み取りが可能な`Hooks`も公開していますが、本記事では一旦リアルタイムアップデートを入手できる`Composition Function`の作成についてのみ話します。

`useCollectionData<T>`を使うと、下記の型の戻り値が得られます。

```typescript
type CollectionData<T> = {
  values: Ref<T[]>,
  loading: Ref<boolean>,
  error: Ref<Error | null>,
}
```

リアクティブに変更される`Collection`の値(values)と、読込中に`True`になる`loading`、読み込みエラー時にエラー内容が入る`error`が返ってきます。

これらは以下の`useMessages`のように具体的な型を指定したアプリケーションのユースケースに沿った別の`composition function`を挟んだ上で`Component`から呼び出して利用します。

```typescript:useMessages.ts
// チャットメッセージのフック。部屋の`ID`を指定すると部屋のメッセージ一覧を取得できる
export const useMessages = (roomIdRef: Ref<string>, option = {
  limit: 20,
}) => {
  const messagesState = useCollectionData<Message>(
    () => firebase.firestore().collection('messages').where('roomId', '==', roomIdRef.value)
      .orderBy('updateTime', 'desc'),
    {
      dataConverter: messageFirestoreDataConverter,
    }
  )

  return {
    ...messagesState,
  }
}
```

```typescript:Component.vue
  setup (props) {
    // @note FirebaseのInitializeは親コンポーネントまたはNuxt Pluginなどにて実行すること
    const messagesState = useMessages(computed(() => props.selectedRoomId))

    return {
      messagesState,
    }
  },
```

`template`は下記のような感じです。

```vuejs:Component.vue
<template>
  <main>
    <section>
      <div v-if="messagesState.loading">
        loading...
      </div>
      <div v-else-if="messagesState.errors">
        {{ messagesState.errors.message }}
      </div>
      <div v-else>
        <ul>
          <li v-for="message in messagesState.values" :key="message.id">
            <span>{{ message.text }}</span>
            <span>{{ message.createTime }}</span>
          </li>
        </ul>
      </div>
    </section>
  </main>
</template>
```

ローディングやエラー表示もリアクティブに取得できるため、このようにシンプルな記述でリアクティブなデータ読み込みを実装できます。

ここで、`setup`の戻り値を下記のようにすると`template`側で`messagesState`と書かなくて良い分書きやすいのでそちらで書いてもいいでしょう。

```typescript
    return {
      ...messagesState
    }
```

## `useCollectionData`の概要

### 設計思想

設計思想は元祖である`react-firebase-hooks`の同名の関数を真似ています。
https://github.com/CSFrequency/react-firebase-hooks/blob/master/firestore/useCollection.ts

こちらのライブラリはもとより`React`から提供されている`Hooks`を活用していたり、共通でローディングを扱う`Hooks`として`useLoadingValue`を実装していたりと、なかなか簡単に複製できそうにないと感じたため、一旦`useCollectionData`と近しいものを再現する方針で取り組んでみました。

### 型

`useCollectionData`の型は下記のように定義しました。

```typescript
export type CollectionData<T> = {
  values: Ref<T[]>,
  loading: Ref<boolean>,
  error: Ref<Error | null>,
}

type useCollectionData = {
  <T extends { id: string }> (
    getQuery: () => firestore.Query,
    option?: {
      dataConverter?: FirestoreDataConverter<T>
    }
  ): CollectionData<T>
}
```

ジェネリック型`T`は`Firestore`から取り出してきたデータの型です。`Firestore`の戻り値は基本的にそのままだと型安全ではないので、この段階で型が付けられるようにしています。

`useCollectionData`の`getQuery`は関数で渡すようになっています。これは`useCollection`内部で下記のようにcomputedでWrapすることでリアクティブにクエリの変更を受け付けられるようにするためです。

```typescript
  const query = computed(() => getQuery())
```

というのも、チャットルームでメッセージを読み込む処理を開発する場合、部屋を何度も切り替えることが想定されるため、部屋を切り替えるたびに新しくデータを読み取ってほしいからです。そこで、検索クエリ内には検索パラメータが必ず含まれることを利用して、検索パラメータの変更に対して即時に再度クエリを投げてもらうよう設計してみました。

戻り値の`CollectionData`については前述の通りです。

オプションの`dataConverter`には、`FirestoreDataConverter`を指定できます。
`Converter`についての仕様は下記ドキュメントを御覧ください。
https://cloud.google.com/firestore/docs/manage-data/add-data?hl=ja#custom_objects

読み込み、書き込み時に`Firestore`上のデータとドメインオブジェクトで変換を掛けることができるハンドラのようなものです。個別のモジュールとして作っておいて適宜`import`して使うと良いと思います。

特に`Date`は`Firestore`上のデータの型は`Timestamp`型として扱われているので、`dayjs`等で日付のフォーマットを掛けるために下記のように実装して`Converter`としています。

```typescript:messageFirestoreDataConverter.ts
export const messageFirestoreDataConverter: FirestoreDataConverter<Message> = {
  toFirestore (message: Message): DocumentData {
    return message
  },
  fromFirestore (snapshot: QueryDocumentSnapshot<FirebaseMessage>): Message {
    const { createTime, updateTime, ...contents } = snapshot.data()

    return {
      createTime: convertTimestampToDate(createTime),
      updateTime: convertTimestampToDate(updateTime),
      ...contents,
    }
  },
}
```

### データの読み取り

データの読み取りとそれに関するステートは下記コードによって取得しています。

```typescript
  const {
    snapshot, loading, error,
  } = useCollection<T>(query, option)
```

`useCollectionData`やここで使われる`useCollection`では徹底的にジェネリック型を使って、ドメインオブジェクトの型を取り回し続けるように組んでいます。

`useCollection`は下記の通りです。

```typescript
export const useCollection = <T> (
  getQuery: () => firestore.Query,
  option?: {
    dataConverter?: FirestoreDataConverter<T>
  }
) => {
  const snapshotState = reactive<SnapshotState>({
    snapshot: undefined,
    loading: true,
    error: null,
  })

  const query = computed(() => getQuery())

  let listener = () => {
  }

  const bindSnapshotHandler = () => {
    if (!query.value) return

    listener()
    snapshotState.loading = true

    const builtQuery = option?.dataConverter ? query.value.withConverter(option.dataConverter) : query.value

    listener = builtQuery.onSnapshot({
      next: val => {
        snapshotState.snapshot = val
        snapshotState.loading = false
      },
      error: err => {
        snapshotState.error = err
        snapshotState.loading = false
      },
    })
  }

  // マウント完了時か、クエリが変化したときにデータを読み込む
  watch(query, bindSnapshotHandler, {immediate: true})
  onUnmounted(() => {
    listener()
  })

  return {
    ...toRefs(snapshotState),
  }
}
```

前述の`DataConverter`を必要に応じて使いながら、`watch`で`snapshot`の取得を試みています。
`Converter`の型定義にジェネリック型が必要になるので、ここまでずっとジェネリック型`T`を取り回し続けています。

今回はこのように、`immediate: true`で即時実行するようにフックを組みましたが、実行するハンドラごと呼び出し元に返して、呼び出し元の裁量で実行タイミングを決められる設計もありだと思います。
今回の応用版として、`useDocumentCreate.ts`も作っているのですが、そちらは`create`というハンドラを呼び出し元に返しておき、チャットメッセージの送信ボタン押下のイベントハンドラで`call`しています。

## わからないこと

**2020/09/20追記**

下記のわからないことですが、クロージャを引数で受け取って、composition function内部でcomputedでWrapすればリアクティブになるので、そのほうが望ましいことに気が付きました。`useSWRV`がその方式を採用しています。

----

これでとりあえず動作するわけなのですが、ベストプラクティスかどうか分かっていない点としては、下記のように`ComputedRef`を`composition function`が受け取る点です。

```typescript
  const readState = useCollectionData<Message>(
    computed(() => firebase.firestore().collection('messages').where('roomId', '==', roomIdRef.value)
      .orderBy('updateTime', 'desc')),
    {
      dataConverter: messageFirestoreDataConverter,
    }
  )
```

あるページ内でクエリが次々変化するケースにおいてのカスタムフックってこのように作る方針でいいんでしょうか。
普通に`Query`型を渡してしまうと、`Query`を更新したときに再度フックが走らないのでデータが更新されないわけです。
こうなるくらいなら、ハンドラを呼び出し元に返す設計に組み直して、呼び出し元で`watch`などを基点に再読み込み動かすほうがスッキリ来るような気もする。

## まとめ

自信がない箇所もありますが、なんにせよ通信処理とエラー、ローディングがリアクティブに扱えるモジュールを切り出しやすい`composition-api`は便利です。`React`の`Hooks`が羨ましいと思っていたのですが`composition-api`のほうがリアクティブな値の型が明示されている点で直感的な気がしていて好みです。色々試行錯誤してきたいと思います。

今のところ、以下でツイートしているように、インフラ層と、`DTO`(というかドメインオブジェクトの型)に変換する層と、アプリケーション固有の仕様を表現する層に`Composition Function`のレイヤーを分けて、ネストさせて扱うと見通しが良さそうに思っています。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">インフラ：useApi/useFirestoreCollectionなど<br>DTO変換：useArticle/useMessageなど<br>アプリケーション：画面特有の、ここのイベントが動いたらここのデータを再読込するみたいな動きを実装する</p>&mdash; 名人さん | マナリンクCTO (@Meijin_garden) <a href="https://twitter.com/Meijin_garden/status/1301047684778700805?ref_src=twsrc%5Etfw">September 2, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

よろしければTwitterのフォローお願いします！
https://twitter.com/Meijin_garden