👀

RecoilでFirebase Authを購読する

2021/06/26に公開

概要

FirestoreやCloudStorageにリクエストを投げる時、大抵の場合はFirebase Authでログインした状態でリクエストを送信します。この時、Firebase Authでログインされているか否かをHooksで取得できると非常に便利です。また、ログイン状態によって表示するコンポーネントを切り替えられると表現の幅も広がります。

実装

今回は以下のモジュールを実装します。

  • ログイン、購読状態を格納するステート
  • ログインしているuidを取得するためのセレクタ
  • ログイン状態を購読イベントを発行するエフェクター
  • ログイン状態を購読するためのUIComponent

ログイン、購読状態を格納するAtomの定義

購読するためにfirebase.auth().onAuthStateChangedを使用しますが、このメソッドを使用する時以下の状態が考えられます。

  • 購読していない & ログインしていない
  • 購読していない & ログインしている
  • 購読している & ログインしていない
  • 購読している & ログインしている
    この状態を表現するために以下のAtomを定義します。
store/auth.ts
interface Subscription {
  uid?: string
}

interface Atom {
  subscription?: Subscription
}

onAuthStateChangedで監視している間はatomのsubscriptionにSubscriptionがセットされ、ログイン済みであればsubscriptionのuidにログイン済みのuidがセットされるような実装で進めます。

Subscriberの定義

実際にfirebase.auth().onAuthStateChangedを使用してログイン状態を監視していきますが、HooksAPIはカスタムフックを定義することができます。今回はログイン状態を監視し、ログイン状態が変化した時にatomを更新するフックを実装します。

store/auth.ts
export const useListenAuth = () => {
  const [auth, setAuth] = useRecoilState(state)
  useEffect(() => {
    if (auth.subscription) return
    const unsubscribe = firebase.auth().onAuthStateChanged((user) => {
      setAuth(atom => ({ ...atom, subscription: { uid: user?.uid ?? undefined } }))
    })
    return () => {
      unsubscribe()
      setAuth(atom => ({ ...atom, subscription: undefined }))
    }
  }, [])
}

ここで気をつけるポイントが二つあります。

  • useEffectはマウント時のみ発火させる。
  • setAuthはアロー関数を渡す
  • Page or Template層のみで使用する。
    監視を開始するイベントは基本一回のみで十分で、例えばstateが変化するたびに発火するとなると再生成のコストが増えます。加えて予期せぬレンダリングが走ることも考えられます。
    上記の実装では
if (auth.subscription) return

を挟み複数のuseListenAuthをcallしたときに複数の監視イベントが発火されないようにチェックを入れています。複数の場所でuseListenAuthを呼ばないことを保証することができれば、useSetRecoilStateを使用しsetAuthのみ定義することができます。

selectorの実装

uidなどの頻繁に使用するフィールドを呼ぶ際、毎回atomをuseRecoilStateで監視し、state.subscription?.uidとアクセスするのは大変なのでselectorを定義します。

export const uidSelector = selector({
  key: "auth/uid", get: ({ get }) =>
    get(state).subscription?.uid
})

以上を使用することで監視している間は

const uid = useRecoilValue(uidSelector)

と扱うことができるようになりました。

上記の実装をまとめると以下のようになります。

store/auth.ts
import { atom, selector, useRecoilState } from "recoil";
import { useEffect } from 'react'
import firebase from "~/modules/firebase";
import "firebase/auth";

interface Subscription {
  uid?: string
}

interface Atom {
  subscription?: Subscription
}

const state = atom<Atom>({
  key: "auth",
  default: { isSubscribed: false },
});

export const uidSelector = selector({
  key: "auth/uid", get: ({ get }) =>
    get(state).subscription?.uid
})

export const useListenAuth = () => {
  const [auth, setAuth] = useRecoilState(state)
  useEffect(() => {
    if (auth.subscription) return
    const unsubscribe = firebase.auth().onAuthStateChanged((user) => {
      setAuth(atom => ({ ...atom, subscription: { uid: user?.uid ?? undefined } }))
    })
    return () => {
      unsubscribe()
      setAuth(atom => ({ ...atom, subscription: undefined }))
    }
  }, [])
}

export default state;

ログイン状態を監視するUIComponentの定義

監視を開始するのみであれば以下のコンポーネントをPage, Template層に追加するだけで十分です。また、ログイン状態から表示するUIを出し分けるようなロジックを含めてしまいたくなりますがコンポーネントの複雑度が増加するので別コンポーネントで定義してあげる方が良さそうです。

components/subscribedauth.tsx
import { FC } from 'react'
import { useListenAuth } from '~/store/auth'
const SubscribedAuth: FC = ({ children }) => {
  useListenAuth()
  return <>{children}</>
}

ログインしている場合にのみ表示するUIComponent

components/subscribedauth.tsx
import { FC } from 'react'
import { uidSelector } from '~/store/auth'
const LoggedIn: FC = ({ children }) => {
    const uid = useRecoilValue(uidSelector)
    return <>{uid && children}</>
}

まとめ

RecoilがHooksAPIと強く紐づいているのでReact.FCの実装が肥大化してしまうことが悩みの種でした。今回はコンポーネントとロジックの分離を果たすことができ、それなりに可読性の高い実装ができました。今後もRecoilの良い書き方を探っていきたいと思います。何か実装方法について質問や、問題の指摘などありましたらコメントいただけると嬉しいです。

Discussion