SPA(React) + Firebase Authentication でリロード時もログイン情報を維持できるようにする

ちょっとハマったので備忘録
ここで紹介する方法以外でベストプラクティス等あればコメントで教えていただけると嬉しいです

## Firebase Authentication + SPA でよくある設計

Firebase Authentication を使ったログイン機能付き SPA の実装例をググったときに見かける設計として

「認証が必要なページにアクセスされたときは、ログインしていればそのまま表示し、ログインしていなければログインページへ飛ばす」

というのがよくあるパターンだと思います。例えば React Router を使うと次のような実装が考えられます。　
※ React Router ドキュメントのコードをベースにしています https://reactrouter.com/web/example/auth-workflow

```tsx

const PrivateRoute: React.FC<RouteProps> = ({ children, ...rest }) => {
  // getUser は保持されたユーザー情報を取得するための何らかの関数です。実装は省略
  // 後で登場する setUser 関数で保持したユーザー情報を取り出します
  // state を使ってもいいし、Redux 等のストアを使ってもいいし、Context を使っても構いません
  const user = getUser();

  return (
    <Route
      {...rest}
      render={({ location }) => {
        if (user) {
            return children;
        } else {
            return <Redirect to={{ pathname: 'login', state: { from: location } }} />;
        }
      }}
    />
  );
};

export default function App() {
  useEffect(() => {
    firebase.auth().onAuthStateChanged((user) => {
      if (user) {
        // setUser はユーザー情報を保持しておくための何らかの関数です。実装は省略
        // state を使ってもいいし、Redux 等のストアを使ってもいいし、Context を使っても構いません
        setUser(user);
      }
    });
  }, []);

  return (
    <>
      <Router>
        <Link to="/public">ログインが不要なページ</Link>
        <Link to="/private">ログインが必要なページ</Link>
        <Switch>
　　　　　　<Route path="/login">
            <>
              <div>ログインページです</div>
              {/* Firebase Authentication へのログイン方法は何でも OK ですが */}
              {/* ここでは StyledFirebaseAuth を使ってログイン画面を表示します  */}
              <StyledFirebaseAuth ... />
            </>
          </Route>
          <Route path="/public">
            <div>このページは誰でも見ることができます</div>
          </Route>
          <PrivateRoute path="/private">
            <div>このページはログインしたユーザーだけが見ることができます</div>
          </PrivateRoute>
        </Switch>
      </Router>
    </>
  );
}
```

この実装で基本的には問題ないです。トップページ `/` から `/public` に遷移するとログインの有無に関係なくページが表示され、 `/private` に遷移するとログインしていれば表示されるし、ログインしていなければログインページへリダイレクトされます

## 問題点

ページ遷移でアプリケーション内をぐるぐるする分には問題ないのですが、 `/private` にいる状態でページをリロードしてみると先程ログインしたのにまたログインページへ飛ばされてしまいます
Firebase Authentication 的にはログイン状態ではあるのですが、 `firebase.auth().onAuthStateChanged` は非同期で走るので、 `/private` にダイレクトにアクセスすると `setUser` がされる前に `getUser` が実行されてしまいます
`setUser` されてないのでユーザー情報は存在せず、ログインされていない判定になりログインページへ再び飛ばされてしまいます

## 解決方法

実装を工夫してあげる必要があります
解決方法はシンプルで `onAuthStateChanged` が終わってからページを表示するかログインページへ飛ばすかどうかを確定させるアプローチで対処できます
（ここでは React Router を使った場合の解決方法を書いていますが、他のルーターの場合でもミドルウェアを使って似たようなことができると思います）

`PrivateRoute` を次のように書き換えてあげます

```tsx

const PrivateRoute: React.FC<RouteProps> = ({ children, ...rest }) => {
  const [authChecked, setAuthChecked] = useState(false);

  useEffect(() => {
    firebaseAuth.onAuthStateChanged((user) => {
      if (user) {
        setUser(user);
      }

      setAuthChecked(true);
    });
  }, []);

  const user = getUser();

  return (
    <Route
      {...rest}
      render={({ location }) => {
        if (authChecked) {
          if (user) {
            return children;
          } else {
            return <Redirect to={{ pathname: 'login', state: { from: location } }} />;
          }
        } else {
          return <></>;
        }
      }}
    />
  );
};
```

「ログイン状態の確認が終わったか」「ログインしているかどうか」の2段階で分岐することで UX を損なうことなくリロード問題に対処できるようになりました