Recoil Atom Effects で Firebase Authentication の認証状態を管理【後編】
この記事は下記記事の後編です。
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
の状態を取得するフックです。このauthUserState
はAtom effects
からのみ更新されるべきなので、atom
自体もセット関数も公開しません。
コンポーネントでの使用例
ここでは認証状態でコンポーネントを出し分けしていますが、ルートオブジェクトを認証状態によって切り替えるなどコンポーネント側の実装は色々あります。
ここで大事なのはReact.Suspense
とRecoilRoot
の下位コンポーネントで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
すごい参考になりました!
ただ、手元の環境ではコンポーネントでの使用例にあるような
RecoilRoot
がReact.Suspense
の配下にある形だとフォールバックコンポーネントが表示されたままになり、ルーティングされませんでした。そこで、
RecoilRoot
をReact.Suspense
の上位にくるように変更するとうまくいったのですが、これらの位置関係は意識しなくても良いものなのでしょうか?(作り的な違いによる現象?)コメントありがとうございます。
ご指摘の通り
RecoilRoot
がReact.Suspense
の上位にくるべきです!記事も修正しました。通常は
AuthSwitch
の親コンポーネントを作ってそこでSuspense
を設置するのですが記事用に色々省略する中でおかしくなっていたようです。どうやら React17 では逆でも動いていたので間違いに気づきませんでしたが、React18 から
Suspense
の仕様が少し変わって動かなくなっていたようです。ただ React18 の仕様変更が無くともやはり
Suspense
はAuthSwitch
のすぐ上にあるのがその役割を考えても正しいと思います。ご返信ありがとうございます。
そうだったんですね!確かに手元の環境もReact18です。
作りの違いによるものではなく仕様だったこと、
位置関係も意識する必要があることがわかって良かったです!