🐳

Recoil Atom Effects で Firebase Authentication の認証状態を管理【後編】

2022/03/16に公開約4,700字3件のコメント

この記事は下記記事の後編です。

https://zenn.dev/riemonyamada/articles/ad38200a1c7fa3

firebase Authentication

さっそく認証状態管理の実装です。

認証状態の監視

まずは Firebase の認証状態を監視する部分です。
Firebase ではonAuthStateChangedちう認証状態を監視する関数があるのでこれを Recoil のAtom Effectsと組み合わせて使います。

Atom Effects内でサブスクライブの起動も廃棄もできるので、別途コンポーネントでuseEffectを使う必要もありません。

import { getAuth, onAuthStateChanged } from 'firebase/auth';
import { atom, useRecoilValue } from 'recoil';
import { AuthUser } from '../types';

const auth = getAuth();

const authUserState = atom<AuthUser | null>({
  key: 'authUser',
  default: null,
  effects: [
    ({ setSelf, trigger }) => {
      // a: 最初の認証状態を取得した時に解決するPromiseを初期値に設定
      let resolvePromise: (value: AuthUser | null) => void;
      const initialValue = new Promise<AuthUser | null>((resolve) => {
        resolvePromise = resolve;
      });
      setSelf(initialValue);

      // b: 認証状態の監視
      const unsubscribe = onAuthStateChanged(auth, (user) => {
        // c: Userから必要なプロパティだけ取り出す
        if (user) {
          const { uid, email, displayName, metadata } = user;
          const authUser = {
            uid,
            email,
            displayName,
            metadata,
          };
          resolvePromise(authUser);
          setSelf(authUser);
        } else {
          resolvePromise(null);
          setSelf(null);
        }
      });
      // d: 監視を終了する関数を返す
      return () => {
        unsubscribe();
      };
    },
  ],
});

// e: atomの値をサブスクライブするフック
export function useAuthUser() {
  return useRecoilValue(authUserState);
}

atom自体にはAuthUserという別途定義したタイプでユーザー情報を保持します。Firebase のUserインスタンスにはユーザー削除メソッドなどもぶらさがっており、それらをコンポーネントなどで使わないようにするためです。

a ではatomの初期値としてPromiseをセットしています。このプロミスは b で最初の認証状態を受け取ったときに解決されます。初期値をPromiseにしているのは最初の認証状態を得るまではReact.Suspenseにレンダリングをまかせるためです。
これによって最初の認証状態を得るまでの処理をコンポーネントでやる必要は無くなります 😄

b で認証状態の監視を開始します。ログイン済みであればuser:Userを受け取りそこから必要なプロパティをコピーしてauthUser:AuthUserを作りatomにセットします。また、さっきのPromiseもここで解決します。

d ではonAuthStateChangedによる監視を終了する関数を返しています。このatomが dispose される際に実行されます。

e はこのatomの状態を取得するフックです。このauthUserStateAtom effectsからのみ更新されるべきなので、atom自体もセット関数も公開しません。

コンポーネントでの使用例

ここでは認証状態でコンポーネントを出し分けしていますが、ルートオブジェクトを認証状態によって切り替えるなどコンポーネント側の実装は色々あります。
ここで大事なのはReact.SuspenseRecoilRootの下位コンポーネントでuseAuthUserを利用することです。
React.Suspenseが初期値のPromiseを処理してくれるのでAuthSwitchは認証状態を同期的に扱えます。

function AuthSwitch() {
  const authUser = useAuthUser();

  return authUser ? <ProtectedArea /> : <PublicArea />;
}

export function App() {
  return (
    <ErrorBoundary fallbackRender={ErrorFallback}>
      <RecoilRoot>
        <React.Suspense fallback={suspenseFallback}>
          <AuthSwitch />
        </React.Suspense>
      </RecoilRoot>
    </ErrorBoundary>
  );
}
  • 2022/06/08 に修正(修正内容はコメント欄に記載)

まとめ

非同期の状態検知はAtom Effectsを使うことでコンポーネントと別ライフサイクルで管理することができます。
複数のコンポーネントから参照されたり、参照するコンポーネントが廃棄されたり、再初期化されることを気にせずにすみます。
また、コードもすっきりします。

アプリ全体で使うデータをFirestoreからサブスクライブする際にも同様の方法が使えます。

あとは、Recoil にガベージコレクションが実装されれば他の部分でも気軽に使えるようになります。

参考に以下にサインイン、サインアウトの際に使えるフックの実装例も載せています。
コンポーネント側の利用例や、サインイン、サインアウトフックはAtom Effectsを使わなくてもあまり変わらないので参考です。

サインイン、サインアウトの例

サインイン、サインアウトするコンポーネントで以下のフックを使います。
ここではauthの状態を参照する必要はありません。Firebase のサインイン、サインアウト関数を使えば、上記のauthUserStateが認証状態の変化を検知してくれます。

import { useState } from 'react';
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';

const auth = getAuth();

export function useSignIn() {
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<Error>();

  // eslint-disable-next-line max-len
  const signIn = (email: string, password: string) => {
    setLoading(true);
    return signInWithEmailAndPassword(auth, email, password)
      .then(() => {
        console.log('signed in!');
      })
      .catch((e) => {
        setError(e instanceof Error ? e : Error('unecpected error!'));
      })
      .finally(() => {
        setLoading(false);
      });
  };

  return {
    signIn,
    loading,
    error,
  };
}
import { useState } from 'react';
import { getAuth, signOut as _signOut } from 'firebase/auth';

const auth = getAuth();

export function useSignOut() {
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<Error>();

  const signOut = () => {
    setLoading(true);
    return _signOut(auth)
      .then(() => {
        console.log('signed out!');
      })
      .catch((e) => {
        setError(e instanceof Error ? e : Error('unecpected error!'));
      })
      .finally(() => {
        setLoading(false);
      });
  };

  return {
    signOut,
    loading,
    error,
  };
}

Discussion

すごい参考になりました!

ただ、手元の環境ではコンポーネントでの使用例にあるようなRecoilRootReact.Suspenseの配下にある形だとフォールバックコンポーネントが表示されたままになり、ルーティングされませんでした。

そこで、RecoilRootReact.Suspenseの上位にくるように変更するとうまくいったのですが、これらの位置関係は意識しなくても良いものなのでしょうか?(作り的な違いによる現象?)

App.tsx
function App() {
    return (
        <>
+           <RecoilRoot>
                <React.Suspense fallback={<div>読み込み中</div>}>
-                 <RecoilRoot>
                    <BrowserRouter>
                        <Router/>
                    </BrowserRouter>
-                 </RecoilRoot>
                </React.Suspense>
+           </RecoilRoot>
        </>
    );
}

コメントありがとうございます。

ご指摘の通りRecoilRootReact.Suspenseの上位にくるべきです!記事も修正しました。
通常はAuthSwitchの親コンポーネントを作ってそこでSuspenseを設置するのですが記事用に色々省略する中でおかしくなっていたようです。

どうやら React17 では逆でも動いていたので間違いに気づきませんでしたが、React18 からSuspenseの仕様が少し変わって動かなくなっていたようです。
ただ React18 の仕様変更が無くともやはりSuspenseAuthSwitchのすぐ上にあるのがその役割を考えても正しいと思います。

ご返信ありがとうございます。

そうだったんですね!確かに手元の環境もReact18です。
作りの違いによるものではなく仕様だったこと、
位置関係も意識する必要があることがわかって良かったです!

ログインするとコメントできます