📦

useSyncExternalStore を使った Firebase Authentication の状態管理

2023/08/24に公開

Firebase Authentication では onAuthStateChanged を利用することで、ログインしているユーザーを取得することが出来ます。
https://firebase.google.com/docs/auth/web/manage-users#get_the_currently_signed-in_user

useEffect を使った管理

今までは以下のように useEffect を使ってオブザーバーからユーザーを取得し state に入れていました。

const [user, setUser] = useState<FirebaseUser | undefined>();
useEffect(() => {
  const auth = getAuth(firebaseApp);
  const unsubscribe = onAuthStateChanged(auth, (user) => {
    if (user) {
      setUser(user);
    } else {
      setUser(undefined);
    }
  });
}, [])

useSyncExternalStore を使った管理

しかし React 外部の状態を扱う useSyncExternalStore が出来たので useEffect を使わずに Firebase Authentication の状態管理を行うようにしました。
useSubscribeAuthStateChanged をアプリケーションのルートあたり(Next.js であれば App あたり)に設置し AuthState を Context の Provider に渡します。
AuthState を使いたい場所では Context から値を取得しています。

import { User, getAuth, onAuthStateChanged } from "firebase/auth";
import { useState, useSyncExternalStore } from "react";

type AuthState = {
  status: "loading" | "login" | "logout";
  user: User | undefined;
};

export const useSubscribeAuthStateChanged = () => {
  // FirebaseApp を取得する hook を別で実装しています
  const app = useFirebaseApp();
  // 状態を保持する store は useState の初期化処理で生成し state として保持しています
  // store の再生成を行わないための処置で state の更新は行いません
  // この useState を用いる方法は react-query で useSyncExternalStore を利用している部分を参考にしました
  // https://github.com/TanStack/query/blob/845751180decd86b2feab669e487a2887e12f8b5/packages/react-query/src/useBaseQuery.ts#L68-L95
  const [store] = useState(() => {
    return getStore(app);
  });

  const state = useSyncExternalStore<AuthState>(
    store.subscribe,
    store.getSnapshot,
    store.getServerSnapshot,
  );

  return state;
};

const initialState: AuthState = { status: "loading", user: undefined };

const getStore = (app: ReturnType<typeof useFirebaseApp>) => {
  let state: AuthState = initialState;

  return {
    getSnapshot: () => state,
    getServerSnapshot: () => initialState,
    subscribe: (callback: () => void) => {
      const auth = getAuth(app);
      const unsubscribe = onAuthStateChanged(auth, (user) => {
        if (user) {
          state = {
            status: "login",
            user: user,
          };
        } else {
          state = {
            status: "logout",
            user: undefined,
          };
        }
        callback();
      });

      return () => {
        unsubscribe();
      };
    },
  };
};

useSyncExternalStore で扱う状態の保持は以下の state に持たせています。
この useState を用いる方法は react-query で useSyncExternalStore を利用している部分を参考にしました。
https://github.com/TanStack/query/blob/845751180decd86b2feab669e487a2887e12f8b5/packages/react-query/src/useBaseQuery.ts#L68-L95

const [store] = useState(() => {
  return getStore(app);
});

getStore 関数は、内部に変数 state を持たせており、この値を useSyncExternalStore の snapshot として渡しています。

const getStore = (app: ReturnType<typeof useFirebaseApp>) => {
  let state: AuthState = initialState;
  
  // 略
}

感想

getStore 関数を用意せず、単純に let state: AuthState を変数として用意しても、おそらく問題はありません。
ただ、以下のように変数を用意して取り回す作りは、グローバル変数みたいで個人的に好みでは無かったため(Server-side Renderingで状態が共有されるようなバグを仕込んでしまいそう)、今回の useState の初期化処理で生成し、更新しない state に持たせる方法を取りました。

// auth.ts
let state: AuthState = initialState;

export const useSubscribeAuthStateChanged = () => {/* 略 */};

Discussion