Reactのログイン認証の状態維持についてハマったので備忘録として残しておきます。
バックエンドはLaravelを使用していますが今回はあまり関係がないので取り上げません。
使用技術
- React17
- React Router Dom v6
- Recoil
- Laravel9
- MUI v5
Atom
import { atom, useRecoilState } from "recoil";
type userState = { id: string; name: string } | null;
const userState = atom<userState>({
key: "user",
default: null,
});
export const useUserState = () => {
const [user, setUser] = useRecoilState<userState>(userState);
return { user, setUser };
};
RecoilRoot
import React from "react";
import ReactDOM from "react-dom";
import { RecoilRoot } from "recoil";
import App from "./App";
ReactDOM.render(
<RecoilRoot>
<App />
</RecoilRoot>,
document.getElementById("app")
);
axios
import axios from "axios";
export const axiosApi = axios.create({
baseURL: "http://localhost",
withCredentials: true,
});
hooks
import { useUserState } from "@/atoms/user";
import { axiosApi } from "@/lib/axios";
const useUserAuth = () => {
const { user, setUser } = useUserState();
//ログイン済みか確認
const userStatus = () => {
return user ? true : false;
};
//Laravel側にログイン状態を確認
const fetchUser = async (): Promise<boolean> => {
if (user) {
return true;
}
try {
const res = await axiosApi.get("/api/user");
if (!res.data) {
setUser(null);
return false;
}
setUser(res.data);
return true;
} catch {
return false;
}
};
return { userStatus, fetchUser };
};
export default useUserAuth;
ルーティング
ダメだった書き方
import React, { useEffect } from "react";
import { Routes, Route, Navigate, Outlet } from "react-router-dom";
import useUserAuth from "@/hooks/useAuth";
import Login from "@/components/pages/login";
import TopPage from "@/components/pages/index";
import TaskPage from "@/components/pages/tasks";
const App: React.FC = () => {
const { userStatus, fetchUser } = useAuth();
useEffect(() => {
const init = async () => {
// ログイン中か判定
await fetchUser();
return await fetchUser();
};
init();
}, []);
//ログインしていなかったらログインページにリダイレクトする
const RouteAuthGuard = () => {
return userStatus() ? <Outlet /> : <Navigate to="/login" replace />;
};
return (
<Routes>
<Route element={<RouteAuthGuard />}>
<Route path="/" element={<TopPage />} />
<Route path="/task" element={<TaskPage />} />
</Route>
<Route
path="/login"
element={userStatus() ? <Navigate to="/" /> : <Login />}
/>
</Routes>
);
};
export default App;
問題点
最初ルーティングのところを上記のように記述していました。
最初にログイン状態を確認し、ログインしていればログイン済みのページを表示させ、ログインしていなければログインページにリダイレクトさせます。
この実装でも、/loginから順にページ遷移させる分には問題なく動作します。
しかし、上記の記述では/taskにいる状態でリロードするとログインページに飛ばされ、
その後トップページに飛ばされてしまいます。
これはLaravelから帰ってくる結果はログイン済みと判定されるのですが、/taskに直たたきでアクセスするとRecoilに認証情報をsetUserでセットする前にuserStatusでログイン状態の確認が実行されてしまうため、未ログイン状態となりログインページに飛ばされてしまいます。
上記の記述の場合、さらにログインページに遷移した後にもログイン状態の確認が実行されます。
その時にはすでにsetUserが実行されているのでログイン状態となり、ログイン後の画面であるトップページに飛ばされるようになってしまいました。
上記の実行のイメージ
ログイン済みの状態で/taskページにアクセスし、リロードしたとします。
/taskページリロード → useStatusでログイン状態の確認 → setUserがされていないため未ログイン状態 → ログインページにリダイレクト → setUser実行 → setUserの結果ログイン状態のため、ログインページからログイン後のページにリダイレクト
解決策
ログイン状態の確認が終わったかどうかを判定するステートを用意します。
これによりログイン状態の確認が完了するまでログインチェックが実行されなくなります。
/taskページでリロードしても問題なくログイン状態が維持できるようになりました。
import React, { useEffect, useState } from "react";
import { Routes, Route, Navigate, Outlet } from "react-router-dom";
import useUserAuth from "@/hooks/useAuth";
import Login from "@/components/pages/login";
import TopPage from "@/components/pages/index";
import TaskPage from "@/components/pages/tasks";
const App: React.FC = () => {
const { userStatus, fetchUser } = useAuth();
// ログイン状態の確認が終わったか
const [authChecked, setAuthChecked] = useState(false);
useEffect(() => {
const init = async () => {
await fetchUser();
return setAuthChecked(true);
};
init();
}, []);
const RouteAuthGuard = () => {
//ログイン状態の確認が完了していればログインチェックさせる
if (authChecked) {
return userStatus() ? <Outlet /> : <Navigate to="/login" replace />;
} else {
return <></>;
}
};
return (
<Routes>
<Route element={<RouteAuthGuard />}>
<Route path="/" element={<TopPage />} />
<Route path="/task" element={<TaskPage />} />
</Route>
<Route
path="/login"
element={userStatus() ? <Navigate to="/" /> : <Login />}
/>
</Routes>
);
};
export default App;