Next.jsでFirebase Authに起因する数秒間の読み込み画面を倒して快適なユーザー体験を勝ち取る

2024/10/01に公開

株式会社トリドリの開発部でフロントエンドを極めているoosawyです。

今回は、WebサービスなどでFirebase Authを使っているときに何気なくやってしまう実装で生まれてしまう読み込み画面を倒し、快適な読み込み体験を勝ち取った話を共有したいと思います。

背景

現在開発しているtoridori marketingではページの読み込みに時間がかかっていました。原因として、Firebase Authを起点とする認証状態の読み込みに1~2秒かかっていて、その間全画面のローディングUIを表示していました。

ところがFirebase Authの読み込みを待たずともページを表示できることが分かりました。

  • Firebase Auth以外のFirebase機能は使っていないから
  • APIリクエストには(サービスが独自に発行し)ブラウザに永続化しているトークンを使っているから

そこでページ表示を高速化したくなりました。読み込み画面がアプリケーションの読み込みを遅らせることになっているので、Firebase Authの読み込みを待たなくても済むようにすれば、高速化を実現できるはずです。

App Routerでセッションベースの認証をしていれば簡単に解決できそうでしたが、toridori marketingでは現在Pages Routerを使っているため、これは試せませんでした。また今後App Routerに移行するとき、Pages Routerとの境界を超える遷移でページ読み込みが発生してしまうので、これも避けたいモチベーションもありました。

この記事には、Firebase Authのための読み込み画面を倒せないか調査、検証した記録を残します。

  • (Firebase Authの読み込みを待たず)並行して表示を開始する
  • Firebase Authの認証状態に直接的に依存する場所だけ、その読み込みが終わるまで待機する

Firebase Authを使うと自然に生まれるAuthContext

本題の前に、まずは以下のコードを見てください。いくらか簡略化してありますが見覚えがあるのではないでしょうか?

import React, { createContext, useEffect, useState } from 'react'
import { getAuth, onAuthStateChanged, type User } from 'firebase/auth'

const AuthContext = createContext<{ user: User | null }>(undefined as never)

export const useAuth = () => {
  return useContext(AuthContext)
}

export const AuthProvider = (props: { children: React.ReactNode }) => {
  const [user, setUser] = useState<User | null | undefined>()

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(getAuth(), (user) => {
      setUser(user)
    })

    return unsubscribe
  }, [])

  if (user === undefined) {
    return <LoadingScreen />
  }

  return <AuthContext.Provider value={{ user }}>{props.children}</AuthContext.Provider>
}

Firebase Authを使うとき、ユーザー情報は onAuthStateChanged の非同期コールバックで渡ってくるので自然とこのようなコードになると思います。
自分の経験上toridori marketingに限らずどのサービスでもFirebase Authを導入した時点にまずAuthContextが生まれます。(そしてこのAuthContextは都合よく様々な処理が追加されることですぐに肥大化することになるので、いずれはAuthContext、さらにFirebase Authも倒したい野望があるのですがそれはまた別の話です。)

注目してほしいのが、このAuthContextで管理している状態 user はログイン状態に対応してUserとnullを取るのですが、加えてログイン状態が確定するまで(onAuthStateChanged のコールバックが最初に呼ばれるまで)undefinedを取るようになっています。これは直接的にはonAuthStateChangedのコールバックが非同期で呼ばれるためですが、Firebase Authが認証状態をクエリが非同期であるIndexedDBに保存していることを知っていれば致し方ないと考えられそうです...が、それだけではありません。

ページ読み込みのたびにFirebase Authが行う認証リクエスト

DevToolsのNetworkタブを注意深く見たことがある人はすでに気づいているかもしれませんが、Firebase Authを使ったときページ読み込みのたびに(正確にはFirebase Authの初期化時に)認証リクエストが飛んでいます。完全なユーザー情報と認証トークンなどはIndexedDBに保存されているのですが、毎回確認のリクエストが飛ぶのです。(この挙動を変更したりすることはできず、楽観的にユーザーがログインしているかどうかだけを取得する方法はissueで何年も提案されていますが実現していません。)

ここまでなら一貫性と速度のトレードオフで常に最新情報にアクセスするという一貫性が選ばれた、そしてそれは些細な速度を気にしない多数のユーザーにとってより受け入れられやすい選択を取ったとして理解できるのですが、残念なことが2つあります。

1つ目はそのリクエストのレスポンスが速くないことです。特段遅いわけではないですが250ms程度かかることがあり、そして送信先のサーバーにアクセスしてみたところ404ページでも同等のレスポンス時間のため、おそらくこの大部分が北アメリカにあるサーバーとのRTTによるものだと見受けられるところです。またサーバー側にユーザー情報のキャッシュがあるので連続してリクエストした場合にはやや早くなることもありますが、少し時間を空けるとレスポンス時間がやや伸びます。

そして2つ目はFirebase Auth内で管理しているユーザーのトークンが期限切れになった場合、前述したリクエストの前に追加でリクエストが飛ぶことです。そして同じく250ms程度かかるのです。

さらにtoridori marketingではプロダクト固有の事情で事態はより悪化しています。それは直接的な当人認証に用いるFirebase Auth上でのユーザーアカウントとは別に、サービス側のデータベースにユーザー情報を持つMemberと、さらにMemberが所属するCompanyという2つのデータを直列で取得しています。それぞれ100ms程度かかります。

そして詳しくは説明しませんが一番最初に行われるIndexedDBからのデータ取得には度重なる非同期処理が絡み、ページ読み込み直後の同期タスクが多いタイミングではなかなか進まず500msほど出遅れます。

そしてほかの諸々の処理を含めるとページアクセスから読み込み画面が2秒弱ほど表示されることになるのです。

Suspenseによる末端コンポーネントからの読み込み状態の移譲

この読み込み画面を倒す方法の基本的な考え方は簡単です。AuthContextで認証状態が確定するまでアプリケーション全体を隠す代わりに、認証状態・ユーザー情報を実際に使う場所で認証状態の読み込みを待てば良いのです。つまり認証状態に直接依存するUI以外は読み込みを待たずとも表示できるので表示しようという発想です。
Next.jsには様々な最適化があり条件がそろえばページの大枠がビルド時には静的に生成されるので、これで体感速度を大きく改善することが期待できます。

これを実装する方法として使用したいのがReactのSuspenseです。Suspenseはデータが使用されるコンポーネントでそのデータ読み込みなどのPromiseを待ち、データの取得が完了するまで代わりにローディングUIなどを表示できるようにする仕組みです。このローディングUIを表示させるための仕組みがSuspenseの大きな特徴でデータの準備が整っていないときには処理を中断し帯域脱出する(呼び出し元へ巻き戻る)のです。

この帯域脱出はコンポーネントツリー上に表れ、祖先にある一番近い<Suspense fallback={...}>コンポーネントまで遡りこれがサスペンド状態になることでfallbackに指定した代替UIが描画されます。このおかげで認証状態を参照する末端のコンポーネントは読み込まれていなければサスペンドさせることで、その読み込み中という状態を扱わず意識する必要さえなくなります。

今までページ単位やもっと細かいコンポーネント単位で、直接的に認証状態を扱わずAuthContextでアプリケーション全体で条件分岐をして読み込み画面を出していたのは、個々のコンポーネントで読み込み状態という例外的なケースを扱いたくなかったからに他ならないでしょう。認証状態を参照するだけでそのコンポーネントに読み込み状態を導入することになり、それぞれのコンポーネントごとにローディングUIを考えなくてはならなくなります。Suspenseを使うことで読み込み状態のときはサスペンドし、ローディングUIは祖先の<Suspense>コンポーネントに移譲するということが可能になるのです。

Suspenseを武器にグローバルな読み込み画面を倒す

Suspenseを用いることで読み込み状態のハンドリングを移譲し、末端コンポーネントから非同期状態にアクセスしやすいことがわかったので、ここからは実際にFirebase Authの読み込み画面を倒していきます。

実際にやることはシンプルで、Firebase Authが認証状態を読み込み終えるまで解決しないPromiseを作りthrowすることでサスペンドさせるだけです。あとはそのPromiseが解決したときにReactが自動的に再レンダリングしてくれます。

こうすることでAuthContextを参照する各コンポーネントは今まで通りのインターフェースで書くことができ、追加でとりうる状態について考える必要がなくなります。

そして今回キーとなるのは最初の読み込みで非同期にcallbackが呼ばれる onAuthStateChanged() です。最初に例示したようなよくある実装だと User | null | undefined な状態にセットすることでundefinedのときだけまだ読み込み中という状態を表現していました。ここではPromiseを使って最初の呼び出しまでを表現します。

ReactではSuspenseが機能するためにいくつか要件があり、まずPromiseがstable、つまりレンダー間で同じままである必要があるため、useStateにPromiseを保存します。次にPromiseが解決されてないときだけthrowする必要があるのでフラグを管理する必要があります。そして今回はcallbackが呼ばれたタイミングでPromiseを解決させたいので最初にresolve関数を取り出しておく必要があります。なのでこれらを満たすPromiseをラップした簡単な構造であるDeferredを作ります。

// これらは今後 Promise.withResolvers() と React.use() でシンプルに置き換え可能になる
type Deferred = ReturnType<typeof deferred>

const deferred = () => {
  let resolve: () => void
  let resolved = false
  const promise = new Promise<void>((res) => {
    resolve = res
  })
  promise.then(() => { resolved = true })
  return {
    promise,
    resolve: resolve!,
    get resolved() {
      return resolved
    },
    use() {
      if (resolved) return
      throw promise
    },
  }
}

そして前述したようにAuthContextで認証状態が読み込まれるまでサスペンドできるように、onAuthStateChanged が呼ばれたときに解決させるDeferredをstateに持ち、useAuthで参照します。

import React, { createContext, useEffect, useState } from 'react'
import { getAuth, onAuthStateChanged, type User } from 'firebase/auth'

const AuthContext = createContext<{ ready: Deferred, user: User | null | undefined }>(undefined as never)

export const useAuth = (): { user: User | null } => {
  const { ready, user } = useContext(AuthContext)
  ready.use()
  assert(user !== undefined)
  return { user }
}

export const AuthProvider = (props: { children: React.ReactNode }) => {
  const [ready] = useState(() => deferred())
  const [user, setUser] = useState<User | null | undefined>()

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(getAuth(), (user) => {
      ready.resolve()
      setUser(user)
    })

    return unsubscribe
  }, [ready])

  return <AuthContext.Provider value={{ ready, user }}>{props.children}</AuthContext.Provider>
}

動作は次のようになります。

  1. AuthProvider: 最初のレンダーで未解決のPromiseを包んだDeferredを状態に持ち、Contextから提供する
    useEffectでonAuthStateChangedを呼び、Firebase Authが認証状態を読み込むのを待つ
  2. useAuth: AuthProviderからDeferredを参照し、Promiseが未解決なのでthrowする
    useAuthを呼び出すコンポーネントの祖先にある直近のSuspenseがサスペンド状態になり、fallbackが表示される
  3. AuthProvider: onAuthStateChangedのcallbackが呼ばれDeferredを解決する
  4. useAuth: Promiseが解決されたことでサスペンドが解除され、レンダリングされる

このようにすることで前述したとおりuseAuthしたコンポーネントがサスペンドしようとするので、その祖先の適切な位置に<Suspense>を配置することでSkeleton UIなどを表示することができます。1つ注意が必要なのがuseAuthをあまりにもコンポーネントツリーの上位で呼んでしまうと全体レベルでサスペンドしてしまい、表示できたはずのほとんどのUIが隠れてしまうので、コンポーネントを分けたりサスペンドしないバージョンを用意するなどの工夫が必要です。

サーバーサイドレンダリング対応

実は前掲した実装ではサーバーサイドレンダリングでできないという大きな問題があります。これはFirebase Authの認証処理はサーバー側では動かないためpromiseがサーバーサイドで永遠に解決することがないためです。[1]React 18からStreaming SSRが導入されたことで、サーバー側でのサスペンドがストリーミングという形で段階的にHTMLが返されますが、実際にFirebase Authの読み込みをしてpromiseを解決する処理はuseEffectの中にあり、これはサーバー側では実行されません。これはいわゆるリクエストごとのSSRに限らずNext.jsのビルド時に行われる最適化にも影響し、ユーザーがアクセスした際に静的なHTMLが得られるか、つまり即座にUIを表示できるかに関わってきます。

この問題を解決するためには、サーバー側ではpromise/認証状態を参照せず代わりにローディングUIを描画し、クライアント側でFirebase Authの読み込みが完了したら実際のUIを描画することが必要です。つまり巷でみる<ClientOnly>コンポーネントに近いことを行いたいのですが、これではuseAuth()の呼び出し側が何も意識することなく使えるという我々の目的に反してしまいます。

そこで使用したいのが、Reactのサーバーレンダリング時にSuspense内でエラーが発生したときに、サーバー側ではSuspenseのfallbackを描画しクライアント側で改めてレンダリングを試行してくれる挙動です。

この挙動を使ったクライアントサイドレンダリングの切り替えは公式ドキュメントに例示されている方法で、今までのトップダウン的なClientOnlyとは異なりボトムアップ的なアプローチであり、実際にサーバーサイドでの処理をスキップする必要がある場所で明示できるため、よりReactの思想であるコロケーションやコンポジションに沿った方法であると言えるでしょう。

しかしこれには大きな問題があります。この挙動は発生したエラーに関わらずクライアント側でリトライするものであるため、サーバーサイドレンダリングをスキップしたいという意思を込めたエラーを投げたとしても、それはただのエラーとして扱われNext.jsの開発サーバーでは派手なError Overlayが毎回出てしまうのです。(ちなみにビルド後においてはError Overlayはないですが代わりにエラーが発生したことがクライアントに通知され"The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering."というエラーがコンソールに出力されます。)

これでは実用に耐えないので、React側も正当なユースケースがあると考えてかReactDOM.postpone()というここでエラーを投げた時と同様の動作をするAPIを実装しているのですが、現時点でexperimentalでありしばらくは使えません。

しかし他に最高の体験を実現する方法はないので強行することにしてNext.jsをパッチすることにしました。幸いにも独立して実装されているエラーレポート実装に一行追加するというシンプルなパッチで実現できるため、比較的現実的な運用ができると考えています。

クライアント側のレンダリングに切り替えるためのエラーだけを無視することで目的が達成できるので、ここではメッセージがPOSTPONE:から始まるエラーを無視するようにするパッチを適応します。パッチする詳しい方法は省きますが、patch-packagepnpm patchを使ってパッチを適用します。

next+14.2.13.patch
diff --git a/node_modules/next/dist/client/on-recoverable-error.js b/node_modules/next/dist/client/on-recoverable-error.js
index 535e1bd..a45ed75 100644
--- a/node_modules/next/dist/client/on-recoverable-error.js
+++ b/node_modules/next/dist/client/on-recoverable-error.js
@@ -18,6 +18,7 @@ function onRecoverableError(err) {
     };
     // Skip certain custom errors which are not expected to be reported on client
     if ((0, _bailouttocsr.isBailoutToCSRError)(err)) return;
+    if (err.message.startsWith('POSTPONE:')) return;
     defaultOnRecoverableError(err);
 }

diff --git a/node_modules/next/dist/esm/client/on-recoverable-error.js b/node_modules/next/dist/esm/client/on-recoverable-error.js
index 299e9c2..e772ee9 100644
--- a/node_modules/next/dist/esm/client/on-recoverable-error.js
+++ b/node_modules/next/dist/esm/client/on-recoverable-error.js
@@ -8,6 +8,7 @@ export default function onRecoverableError(err) {
     };
     // Skip certain custom errors which are not expected to be reported on client
     if (isBailoutToCSRError(err)) return;
+    if (err.message.startsWith('POSTPONE:')) return;
     defaultOnRecoverableError(err);
 }

これだけだとまだNext.jsのサーバー側でエラーログが出力されてしまうので、こちらはモンキーパッチします。

if (typeof window === 'undefined') {
  const originalConsoleError = console.error
  if (!(console.error as any)['_patched']) {
    console.error = (...args) => {
      if (args[0]?.message?.startsWith('POSTPONE:')) return
      originalConsoleError(...args)
    }
    ;(console.error as any)['_patched'] = true
  }
}

これでthrow new Error('POSTPONE: ...')useAuthに追加して、useAuthを使っているコンポーネントで、サーバーサイドレンダリング時には読み込み画面を表示し、クライアントサイドでFirebase Authの読み込みが完了したら実際のUIを表示することができるようになりました。

export const useAuth = (): { user: User | null } => {
  if (typeof window === 'undefined') {
    throw new Error('POSTPONE: useAuth is client only')
  }
  const { ready, user } = useContext(AuthContext)
  ready.use()
  assert(user !== undefined)
  return { user }
}

リダイレクト前のフラッシュを回避する

ここまでで認証状態の読み込み待ちを遅延させて楽観的にUIを描画できるようになりました。しかし1つ問題が生まれてしまっています。それは例えば認証が必要なページに未ログイン状態でアクセスしたときなど、ページに認証状態が適さない場合に行われるリダイレクトが行われるとき、これを待たずにページが一瞬だけ表示されてしまうことです。これは認証状態が読み込まれる前に楽観的に描画を開始してから認証状態が確定しリダイレクトするまでにタイムラグがあるので、このような挙動になってしまうのです。

一見するとこれは描画するときに条件分岐を入れるだけで回避できそうですが、実はそれほど単純ではありません。最初に表示するページはNext.jsによりSSRやSSG、静的最適化によってHTMLに生成されています。つまりブラウザからアクセスしたときにクライアント側でReactが読み込まれハイドレーション、描画が行われるよりも前に画面上に表示されてしまうのです。このフラッシュを回避するためには、Reactにも、読み込まれる認証状態にも依存せず、かつブラウザが描画を完了する前に同期的に表示を調整する必要があるのですが、これを行う手段はscriptタグに限られてくると思います。

そこでユーザーが以前ログインしたことがあるかどうかというフラグを自前で管理して、フラグがなければ昔ながらの読み込み画面をかぶせて表示するという方法を取ります。従来の読み込み画面をまた復活させることになりますが、ログインが必要でログインページに遷移する場合などサービス利用上例外ケースに相当するため、この読み込み画面はユーザー体験にほぼ影響を与えないでしょう。
理想的には、Firebase Authの認証情報がIndexed DBに保存されているのでこれを参照できると良いのですが、Indexed DBは多くの非同期APIを介さないとデータにアクセスできずフラッシュを回避するためには適しません。そのためlocalStorageやCookieにフラグを保存する必要があります。

もしCookieにフラグを保存し、SSR時に読み込み画面を描画することが可能になるのでかなり率直な実装になりますが、SSRを行うのであれば認証機能の実装を丸ごとすべてサーバー側で行えるので、クライアント側アプローチを取る必要がなくこの記事の趣旨から外れます。単なるストレージとしてcookieを使うことも可能ですが、ここではlocalStorageを使う方法を扱います。

スクリプトの実装は単純です。AuthProviderで読み込み画面とページ内容を両方とも描画するようにし、スクリプトではlocalStorageからフラグを読み取ってページパスと比較し、これらのhidden属性を切り替えることで表示を出し分けるのみです。

const isLoggedIn = localStorage.getItem('is-logged-in') === 'true';
const publicPaths = ['/', '/login', '/signup'];
const unfulfilled = publicPaths.includes(location.pathname) == isLoggedIn;
document.getElementById('auth-app-content').hidden = unfulfilled;
document.getElementById('auth-loading').hidden = !unfulfilled;

AuthProviderは次のようになります。これでユーザーが以前ログインしたことがなく認証が必要なページにアクセスした場合と、ログインしたことがあるときに公開ページにアクセスしたときに読み込み画面を表示することができます。ランディングページなどログイン状態にかかわらず常に表示したいページがある場合はこのスクリプトを調整する必要があります。生のスクリプトを埋め込むことになるのでページ読み込み時のフラッシュを抑えるための最低限の実装に留めると良いでしょう。

hidden属性の操作はhydrationよりも前に行われれ、hydration時に差分が生まれるのでsuppressHydrationWarningを付けることでReactの警告を抑制します。

const publicPaths = ['/', '/login', '/signup']

export const AuthProvider = (props: { children: React.ReactNode }) => {
  // ...

  const id = useId()

  return (
    <AuthContext.Provider value={{ ready, user }}>
      <div id={id + "-content"} suppressHydrationWarning>
        {props.children}
      </div>

      <div id={id + "-loading"} suppressHydrationWarning hidden>
        <LoadingScreen />
      </div>

      <script
        dangerouslySetInnerHTML={{
          __html: `
            document.getElementById("${id}-loading").hidden =
            !(document.getElementById("${id}-content").hidden =
            ${JSON.stringify(publicPaths)}.includes(location.pathname) ==
            (localStorage.getItem("is-logged-in") == "true"));
          `,
        }}
      />
    </AuthContext.Provider>
  )
}

これでページ読み込み時、ページに期待される認証状態と実際の認証状態が異なるときに読み込み画面を同期的に表示するところまで出来ました。あとはリダイレクトが完了したら読み込み画面を隠す必要があります。これを埋め込みスクリプトで実装するのは複雑で、リダイレクトを行うときにはすでにReactが読み込まれているので、ここからはReact内の状態に基づいてhidden属性を切り替えます。

もし埋め込みスクリプト内の条件がもし全てのケースを十分に網羅していなかったとしてもhydration後はReact側の条件でhidden属性を上書きするため、こちらの実装が適切であれば読み込み画面が消えないといったことにはならないでしょう。

ポイントとしてReact側の条件でも user === undefined つまり認証状態が読み込まれていないときにはlocalStorageにあるフラグを参照して読み込み画面を表示するべきか決定するところです。

const publicPaths = ['/', '/login', '/signup']

export const AuthProvider = (props: { children: React.ReactNode }) => {
  const [user, setUser] = useState<User | null | undefined>()

  const router = useRouter()

  // ...

  useEffect(() => {
    if (user === undefined) return
    if (publicPaths.includes(router.pathname)) {
      if (user) router.push('/dashboard')
    } else {
      if (!user) router.push('/login')
    }
  }, [user])

  const loading =
    typeof window !== 'undefined' &&
    publicPaths.includes(router.pathname) === (user != null)

  const id = useId()

  const useLayoutEffect_ =
    typeof window === 'undefined' ? () => {} : useLayoutEffect
  useLayoutEffect_(() => {
    const loading =
      publicPaths.includes(router.pathname) ==
      (user !== undefined
        ? user != null
        : localStorage.getItem('is-logged-in') === 'true')

    document.getElementById(id + '-content')!.hidden = loading
    document.getElementById(id + '-loading')!.hidden = !loading

    console.log({
      path: router.pathname,
      isPublicPage: publicPaths.includes(router.pathname),
      loading,
      user,
    })
  }, [router.pathname, user])

  return (
    <AuthContext.Provider value={{ ready, user }}>
      <div id={id + "-content"} suppressHydrationWarning>
        {props.children}
      </div>

      <div id={id + "-loading"} suppressHydrationWarning hidden>
        <LoadingScreen />
      </div>

      <script
        dangerouslySetInnerHTML={{
          __html: `
            document.getElementById("${id}-loading").hidden =
            !(document.getElementById("${id}-content").hidden =
            ${JSON.stringify(publicPaths)}.includes(location.pathname) ==
            (localStorage.getItem("is-logged-in") == "true"));
          `,
        }}
      />
    </AuthContext.Provider>
  )
}

読み込み中状態だけでなく未ログイン状態もuseAuthフックから排除する

ここまでSuspenseを活用することでuseAuthから読み込み状態のハンドリングを外に移譲するようにしました。これと同様にすることで未ログイン状態のハンドリングも外に移譲することができます。それも単純で if (!user) throw new Promise() を追加するだけです。

export const useAuth = (): { user: User } => {
  if (typeof window === 'undefined') {
    throw new Error('POSTPONE: useAuth is client only')
  }
  const { ready, user } = useContext(AuthContext)
  ready.use()
  if (!user) throw new Promise()
  assert(user)
  return { user }
}

このようにすることでuseAuthに依存するコンポーネントはログイン状態が確定するまでサスペンドしたままになります。ややSuspenseをハックしたような実装にも感じられますが、このようにサスペンドにより処理を中断することでコンポーネントでは未ログイン状態を意識することなく、その間に今まで通りのuseEffectを用いたリダイレクト処理を行うことができます。

もっと柔軟に例外ケースをハンドリングしたいのであれば、カスタムエラーとError Boundaryを活用する方法もあるでしょう。Error Boundaryでエラーを補足した場合でもError Overlayが表示されることに注意が必要です。

class AuthenticationRequired extends Error {}

export const useAuth = (): { user: User } => {
  // ...
  if (!user) throw new AuthenticationRequired()
  // ...
}

class AuthenticationBoundary extends React.Component<{ children: React.ReactNode }> {
  state = { loading: false }

  static getDerivedStateFromError(error: Error) {
    if (error instanceof AuthenticationRequired) {
      return { loading: true }
    }
    return null
  }

  componentDidCatch(error: Error) {
    if (error instanceof AuthenticationRequired) {
      router.push('/login')
    }
  }

  render() {
    if (this.state.loading) {
      return <LoadingScreen />
    }

    return this.props.children
  }
}

まとめ

この記事ではサービス利用においてのハッピーケース、つまり一番多くなるであろうログインしているユーザーがアクセスするケースの体験を最適化するために、読み込み画面を倒す方法を紹介しました。説明の都合上コードの断片のみを示してきたので、実装の全体像を把握したり実際の挙動を確認できるようにデモを用意しました。ぜひ試してみてください。

https://stackblitz.com/edit/stackblitz-starters-6ftebr

この記事では前提をかなり簡略化しているため、アプリケーションの仕様や動作、既存の実装によって調整や工夫が多く必要になるでしょう。toridori marketingでも、一般的には未ログイン状態を前提とする認証系ページの中にアカウント情報登録などのログインが必要なページがあったり、それらに対する入り組んだリダイレクトが歴史的に積み重なっており、ほかにも考慮することが多くありました。

ここまで検証から実装して改めて思い知らされたことですが、やはりクライアント側でのデータ読み込みや一貫性の担保に対する実装はかなり制約が多く複雑になってしまうため、Server Componentsや非同期コンポーネント、Suspenseなどがすべて統合されたApp Routerに期待せずにはいられません。きっと深く考えずとも率直な実装で最高の体験を実現できたと考えています。

今回の実装を始めた一番最初のきっかけとしては、Pages Routerから段階的にApp Routerに移行する際にページ遷移の体験を維持するために必要だと感じたところからだったのですが、一方でApp Routerだと難なくできることをPages Routerで実装していると考えるとこれも皮肉な話で、このようなことをせずに一気にApp Routerに移行してしまうのが近道で、時代の波に乗るにはApp Routerしかないのかもしれません。

株式会社トリドリでは一緒にユーザー体験を深めたり、App Routerの導入を推し進めてくれるフロントエンドエンジニアを積極募集中です!

脚注
  1. まさにこのサーバー側で解決しないpromiseを扱うとレスポンスが返せなくなるという問題を解決する React.postpone() というAPIがReactに実装されようとしているのですが、現時点では利用できません。詳細はNext.jsでなんとしてもPostponeしたいを参照してください。 ↩︎

toridori tech blog

Discussion