Reputation: 1848
In my react app I have guest routes and protected routes. The protected routes are only accessible when the user is authenticated. Next to that, the main app routes are only accessible when the user has signed a contract and finished the onboarding. I'm keeping track of these steps with some extra properties assigned to the user.
My current flow is the following
The user enters the app and the function fetchCurrentUser
is triggered inside the AuthContext Provider. If the call to the database returns data the property isAuthenticated
is set to true and the user data is set to the currentUser
state. If the calls returns an (unauthorized) error isAuthenticated
is set to false. Initially isAuthenticated
is set to null
so I can render a loader as long as isAuthenticated
is null.
Let's assume the user wasn't logged in. Since isAuthenticated
was first null
and now false
the code isn't returning the <h1>Loading</h1>
loader anymore but will return a route. Because /
can't be accessed because isAuthenticated
is false, the app will redirect the user to the /login
page
When the user fills in the credentials and submits the data a cookie is returned from the backend and set in the browser. Now I want to re-trigger the fetchCurrentUser
function to collect the user information. * To do this I set isAuthenticated
back to null and I navigate the user to the dashboard page /
. Since isAuthenticated
is null
the spinner will show up instead but the route is already /
.
In the meantime fetchCurrentUser
will do the api call with the cookie which will return the user data and set isAuthenticated
to true.
Short note for step 3 and 4. I think there are better ways to handle this so please don't hesitate to share a better solution.
Maybe there is a way to call the fetchCurrentUser
from the Login component and wait till the data is set and navigate the user afterwards? Because fetchCurrentUser
is more than an api call and the submit function has to wait till the whole function is done I should work with a promise but inside a promise I can't use async/wait to wait for the api call.
isAuthenticated
is true and the user data is known and stored in the AuthProvider the routes can be rendered again. Since /
is a protected route the code will check if isAuthenticated
is true and check to which route the user needs to be navigated. This part goes wrong Warning: Maximum update depth exceeded
but I don't know what I'm missing.Code to test things out with some fake calls the represent the flow via https://codesandbox.io/s/zealous-meitner-9t000f?file=/src/router/index.js
Login.js
const Login = () => {
const { setIsAuthenticated } = useContext(AuthContext);
const navigate = useNavigate();
const resolver = useYupResolver(loginValidationSchema);
const {
handleSubmit,
register,
formState: { errors },
} = useForm({ resolver });
const submit = async (values) => {
await authService.login(values);
setIsAuthenticated(null);
navigate('/');
};
return (
<div className='w-full border border-grey-300 rounded-lg overflow-hidden shadow sm:mx-auto sm:w-full sm:max-w-md'>
<div className='p-4'>
<form onSubmit={handleSubmit(submit)} className='space-y-6'>
<Input type='text' label='email' name='email' register={register} error={errors.email} />
<Input type='password' label='password' name='password' register={register} error={errors.password} />
<Button type='submit' label='login' color='tenant-primary' />
</form>
</div>
</div>
);
};
AuthContext
const AuthProvider = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(null);
const [currentUser, setCurrentUser] = useState(null);
useEffect(() => {
if (isAuthenticated !== false) {
getCurrentUser();
}
}, [isAuthenticated]);
const getCurrentUser = async () => {
try {
const { data } = await authService.me();
setIsAuthenticated(true);
setCurrentUser(data);
} catch (error) {
setIsAuthenticated(false);
setCurrentUser(null);
}
};
return <AuthContext.Provider value={{ isAuthenticated, setIsAuthenticated, getCurrentUser, currentUser }}>{children}</AuthContext.Provider>;
};
router.js
const Router = () => {
const authContext = useContext(AuthContext);
const GuestRoute = () => {
return !authContext.isAuthenticated ? <Outlet /> : <Navigate to='/' replace />;
};
const ProtectedRoutes = () => {
if (!authContext.isAuthenticated) return <Navigate to='/login' replace />;
else if (!authContext.currentUser?.settings?.is_contract_signed) return <Navigate to='/contract/sign' replace />;
else if (!authContext.currentUser?.settings?.is_onboarding_finished) return <Navigate to='/onboarding' replace />;
else return <Outlet />;
};
if (authContext.isAuthenticated === null) {
return <h1>Loading ...</h1>;
}
return (
<Routes>
<Route element={<GuestRoute />}>
<Route path='/login' element={<Login />} />
</Route>
<Route element={<ProtectedRoutes />}>
<Route path='/' element={<Navigate to='/onboarding' replace />} />
<Route path='/contract/sign' element={<SignContract />} />
<Route path='/onboarding' element={<Onboarding />} />
<Route path='/profile' element={<Profile />} />
</Route>
<Route path='404' element={<NotFound />} />
<Route path='*' element={<Navigate to='/404' replace />} />
</Routes>
);
};
Upvotes: 3
Views: 5673
Reputation: 203208
The is caused by the ProtectedRoutes
unconditionally redirecting to authenticated routes.
const ProtectedRoutes = () => {
if (!authContext.isAuthenticated)
return <Navigate to="/login" replace />; // <-- here
else if (!authContext.currentUser?.settings?.is_contract_signed)
return <Navigate to="/contract/sign" replace />; // <-- here
else if (!authContext.currentUser?.settings?.is_onboarding_finished)
return <Navigate to="/onboarding" replace />; // <-- here
else return <Outlet />;
};
This rerenders the route which rerenders the ProtectedRoutes
component which triggers another redirect, repeat ad nauseam.
The ProtectedRoutes
component should only concern itself with protecting access to a route or redirecting to another route to authenticate. The redirecting to specific protected routes based on user properties should occur in the login logic.
Additionally I highly recommend moving the GuestRoute
and ProtectedRoutes
component declarations out of the Router
component. When these components are redeclared each render cycle it will necessarily unmount and remount their entire sub-ReactTree.
router/index.js
import React, { useContext } from "react";
import { Routes, Route, Outlet, Navigate } from "react-router-dom";
import Login from "../pages/Login";
import SignContract from "../pages/SignContract";
import Onboarding from "../pages/Onboarding";
import Dashboard from "../pages/Dashboard";
import { AuthContext } from "../context/AuthContext";
const GuestRoute = () => {
const authContext = useContext(AuthContext);
return !authContext.isAuthenticated ? (
<Outlet />
) : (
<Navigate to="/" replace />
);
};
const ProtectedRoutes = () => {
const authContext = useContext(AuthContext);
return authContext.isAuthenticated ? (
<Outlet />
) : (
<Navigate to="/login" replace />
);
};
const Router = () => {
const authContext = useContext(AuthContext);
if (authContext.isAuthenticated === null) {
return <h1>Loading...</h1>;
}
return (
<Routes>
<Route element={<GuestRoute />}>
<Route path="/login" element={<Login />} />
</Route>
<Route element={<ProtectedRoutes />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/contract/sign" element={<SignContract />} />
<Route path="/onboarding" element={<Onboarding />} />
</Route>
</Routes>
);
};
export default Router;
pages/Login.js
The handleSubmit
handler should receive the returned user object from the auth service and check the "roles" here to redirect to the appropriate authenticated route.
const handleSubmit = async (e) => {
e.preventDefault();
const data = await authService.login(formData);
if (data) {
const user = await authService.me();
if (user) {
authContext.setIsAuthenticated(true);
if (user?.settings?.is_contract_signed) {
navigate("/contract/sign", {replace: true});
} else if (user?.settings?.is_onboarding_finished) {
navigate("/onboarding", {replace: true} );
} else {
navigate("/", { replace: true});
}
}
}
};
The login logic might also want to implement this in a try/catch to handle any Promise rejections and other thrown errors, and also handle the case where the authentication fails. For example, should there be an error message, or redirect to another special page, etc. Basically this code should handle both the happy and unhappy paths.
Upvotes: 1
Reputation: 19
It shows "Warning: Maximum update depth exceeded" because when you call getCurrentUser() method if isAuthenticated not false the method triggered 2 states and React sees that there are two states and React updated two states in the same time (automatic batching) and when isAuthenticated get new value useEffect() works again it sees that isAuthenticated true ant it is again changes states it happens again again and to infinity for that it show that error
Upvotes: 0