Open2

Suspense-ready useAuth hook for firebase auth

oosawyoosawy
'use client'

import { use, useSyncExternalStore } from 'react'

import './firebase/app'
import { getAuth } from 'firebase/auth'

const subscribe = (callback: () => void) => {
  const auth = getAuth()

  const unsubscribe = auth.onAuthStateChanged(callback)
  return unsubscribe
}

const getSnapshot = () => {
  const auth = getAuth()
  return auth.currentUser
}

let ready: Promise<void>
if (typeof window !== 'undefined') {
  ready = new Promise((resolve) => {
    const unsubscribe = subscribe(() => {
      unsubscribe()
      resolve()
    })
  })
}

export const useAuth = () => {
  if (typeof window === 'undefined') throw new Error('Browser only')

  use(ready) // suspend until firebase auth is ready

  const currentUser = useSyncExternalStore(subscribe, getSnapshot)

  return { currentUser, isLoggedIn: currentUser !== null }
}
oosawyoosawy

ポイントなど

firebase authから認証状態を読み取るのは「外部ストア」に当たるのでuseSyncExternalStoreを使う

"You Might Not Need an Effect"でもSubscribing to an external storeとしてuseSyncExternalStoreを使うことが推奨されています
このhookは元々reduxなどの状態管理ライブラリがReact 18のconcurrent rendering時にtearingというレンダリングの一貫性を破る問題を修正するために作られたものなので、useState&useEffectでcurrentUserを管理するより一貫性が担保されると思われます

currentUserが読み込まれるまでsuspendする

今までのように自前で管理していた場合はuseStateの初期値にundefinedを入れておくなどすれば、一度でも読み込まれたかが分かりましたが、auth.currentUserはそれらを区別せずに初期値がnullなので、読み込み状態を得るためにonAuthStateChangedが最初に呼び出されたときに解決するpromiseを作ってuseしてsuspendさせます[1][2]

firebaseのAPIがNode.jsランタイムなどで実行されるのを防ぐ

firebase js sdkはクライアント側(ブラウザ内)での実行を前提にしていて、サーバー側で実行すると警告が出たりすることもあるので、これを避けるために条件分岐を入れています

特にif (typeof window === 'undefined') throw new Error('Browser only')は単なるassertionではなく、streaming server renderingしているときにエラーがthrowされたコンポーネントを直近のSuspenseまでサーバー側でのレンダリングをスキップするドキュメントにも載っているReactの動作を使っています[3]

残念ながらNext.jsでの開発時にはこのエラーが常に表示されてしまうようです。
bottom-upでsuspenseと協調して動作させるようにするためにはおそらくこの方法しかないので、Next.js側で何らかの対応を待つことになりそうです。

脚注
  1. useはまだreact@stableとしてリリースされていなくRFCもまだマージされていませんが、Next.jsではすでに安全に使えそうです ↩︎

  2. 一見するとuseSyncExternalStoreを使ったがために二度手間になっているようにも見えなくもないですが、従来の実装でも読み込み中(undefined)のときにsuspendさせるためには似たような実装が必要になるはずです ↩︎

  3. 残念ながらNext.jsのpagesのほうでは現時点で完全にstreamingしていなくてこの方法が使えないので、他の方法で実現する必要がありそうです ↩︎