🔖

画面と認証の状態管理と認証のロジックをそれぞれ分離したい(Firebase Authentication + React + TS)

2022/01/29に公開

概要

React + Firebaseにおいて画面と認証の状態管理と認証ロジックをそれぞれ分離したい

ポイント

  • 画面
    • 認証関連はカスタムフックから呼び出して使う
    • firebaseのことを知らない
  • 認証の状態管理
    • 具体的な認証に関わる処理は記述しない
    • firebaseのことを知らない(のが望ましい)
  • 認証ロジック
    • Firebase Authenticationを利用して認証ロジックを書く
    • Firebase Authenticationに関わるものは全部ここにまとめる。
    • 将来別の認証基盤へ移行するとき、ここだけを変えるのが理想

バージョン

  • React 17.0.38
  • Typescript 4.5.4
  • Firebase 9.6.2

処理

画面

トップページの実装です。
ローディング中に<p>loading...</p>を表示します。


const index = (): JSX.Element => {
  const { user, isLoading, signOut } = useAuthContext();

const handleOnClick = async () => {
    await signOut();
    await goto("/sign-in");
  };

  if (isLoading) {
    return <p>loading...</p>;
  }

  return (
    <div>
      <h1>TopPage</h1>
      <p>{user?.uid}</p>
      <button type="submit" onClick={handleOnClick}>
        logout
      </button>
    </div>
  );
};

export default index;

状態管理

useContextを使って状態管理をします。

import React, {
  createContext,
  ReactChild,
  useContext,
  useEffect,
  useState,
} from "react";
import { auth } from "../infrastructure/firebase/init";
import {
  AuthService,
  IErrorText,
  ISign,
} from "../infrastructure/firebase/auth/auth-service";

export interface User {
  uid: string;
}

export interface IAuthContext {
  user: User | null;
  isLoading: boolean;
  signIn: (props: ISign) => Promise<IErrorText>;
  signUp: (props: ISign) => Promise<IErrorText>;
  signOut: () => Promise<void>;
}

const defaultValue: IAuthContext = {
  user: null,
  isLoading: true,
  signIn: async () => null,
  signUp: async () => null,
  signOut: async () => {},
};

const AuthContext = createContext<IAuthContext>(defaultValue);

export const AuthProvider = ({ children }: { children: ReactChild }) => {
  const authService = new AuthService(auth);
  const [user, setUser] = useState<IAuthContext["user"]>(defaultValue.user);
  const [isLoading, setLoading] = useState<IAuthContext["isLoading"]>(
    defaultValue.isLoading
  );

  useEffect(() => {setLoading(false)がはしってしまう
    (async () => {
      // todo ここのUserがfirebaseに依存している,AuthService上で返る値を設定したいがやり方がわからない
      authService.onAuthStateChanged((user: User | null) => {
        setLoading(true);
        if (user) {
          setUser({ uid: user.uid });
        } else {
          setUser(null);
        }
        setLoading(false);
      });
    })();
  }, []);

  const signOut = async (): Promise<void> => {
    await authService.signOut();
  };

  const signIn = async (props: ISign): Promise<IErrorText> => {
    return await authService.signIn(props);
  };

  const signUp = async (props: ISign): Promise<IErrorText> => {
    return await authService.signUp(props);
  };

  return (
    <AuthContext.Provider
      value={{
        user: user,
        isLoading: isLoading,
        signIn: signIn,
        signUp: signUp,
        signOut: signOut,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
};

export const useAuthContext = () => useContext(AuthContext);

認証ロジック

firebaseに関連するロジックは全部ここに入れる

import {
  Auth,
  createUserWithEmailAndPassword,
  onAuthStateChanged as firebaseOnAuthStateChanged,
  signInWithEmailAndPassword,
  User as FirebaseUser,
} from "firebase/auth";
import { signOut as firebaseSignOut } from "@firebase/auth";
import { FirebaseError } from "firebase/app";

type IObserver = (user: FirebaseUser | null) => void;
export type IErrorText = string | null;
export interface ISign {
  email: string;
  password: string;
}

export class AuthService {
  private readonly auth: Auth;

  public constructor(auth: Auth) {
    this.auth = auth;
  }

  // authのproviderの中のuseEffectの中で使う
  public onAuthStateChanged = (observer: IObserver) => {
    firebaseOnAuthStateChanged(this.auth, observer);
  };

  public signOut = async (): Promise<void> => {
    await firebaseSignOut(this.auth);
  };

  public signIn = async (props: ISign): Promise<IErrorText> => {
    let errorText = null;
    try {
      await signInWithEmailAndPassword(this.auth, props.email, props.password);
    } catch (e) {
      if (!(e instanceof FirebaseError)) {
        errorText = "something wrong";
      }
      const firebaseError = e as FirebaseError;
      if (firebaseError.code === "auth/invalid-email") {
        errorText = "メールアドレスの形式が正しくありません";
      } else if (firebaseError.code === "auth/wrong-password") {
        errorText = "パスワードが誤っています";
      } else if (firebaseError.code === "auth/user-not-found") {
        errorText = "ユーザーが存在しません";
      } else {
        errorText = "something wrong";
      }
    }
    return errorText;
  };

  public signUp = async (props: ISign): Promise<IErrorText> => {
    let errorText = null;
    try {
      await createUserWithEmailAndPassword(
        this.auth,
        props.email,
        props.password
      );
    } catch (e) {
      if (!(e instanceof FirebaseError)) {
        errorText = "something wrong";
      }
      const firebaseError = e as FirebaseError;
      if (firebaseError.code === "auth/email-already-in-use") {
        errorText = "すでに使用されているメールアドレスです";
      } else if (firebaseError.code === "auth/invalid-email") {
        errorText = "メールアドレスの形式が正しくありません";
      } else {
        errorText = "something wrong";
      }
    }
    return errorText;
  };
}

おまけ

ログインユーザーでなければログインページへ飛ぶ画面

const index = (): JSX.Element => {
  const { user, isLoading, signOut } = useAuthContext();

  useEffect(() => {
    (async () => {
      console.log(user, isLoading);
      // userがnull かつ ローディング完了
      if (!user && !isLoading) {
        await goto("/sign-in"); // todo 定数ページつくってそこに書く
      }
    })();
  }, [user, isLoading]);

  const handleOnClick = async () => {
    /*
    useEffectの第2引数にuserを設定しているので、signOutによって
    userがnullに変化する、
    それによってsign-inページへ遷移する
     */
    await signOut();
  };

  const goToFirestoreSamplePage = async () => {
    await goto("/firestore-sample");
  };

  if (isLoading) {
    return <p>loading...</p>;
  }

  return (
    <div>
      <h1>TopPage</h1>
      <p>{user?.uid}</p>
      <button type="submit" onClick={handleOnClick}>
        logout
      </button>
      <button type="submit" onClick={goToFirestoreSamplePage}>
        FirestoreSample
      </button>
    </div>
  );
};

export default index;

Discussion