I cannot figure out why useEffect
WILL pick up the change to the state property redirectTo
when I dispatch 'LOGOUT' but it will NOT pickup the change to redirectTo
when I dispatch 'LOGIN'.
anytime a redirect happense I make sure to reset the redirectTo
prop to null
by dispatching 'REDIRECT_RESET'
Can anyone spot why I am getting inconsistent behavior from useEffect
?
Please let me know if I missed any code that would help illustrate the problem.
export function reducer(state: AppState, action: Action): AppState {
switch (action.type) {
case 'LOGIN':
setStorageItem('snc-user-username', JSON.stringify(action.payload.username))
setStorageItem('snc-user-token', JSON.stringify(action.payload.token))
return {
...state,
user: { ...state.user, name: action.payload.username },
redirectTo: '/',
token: action.payload.token,
}
case 'LOGOUT':
removeStorageItem('snc-user-username')
removeStorageItem('snc-user-token')
return { ...state, user: { name: '', email: '', id: '' }, redirectTo: '/login', token: '' }
case 'REDIRECT_RESET':
return { ...state, redirectTo: null }
default:
return state
}
}
const router = createBrowserRouter([
{
path: '/login',
element: <Login />,
},
{
path: '/',
element: <Layout />,
children: [
{
path: '/',
element: <HomePage />,
}
],
},
])
export function Router() {
return <RouterProvider router={router} />
}
export default function App() {
const [state, dispatch] = useReducer(reducer, initialState)
return (
<MantineProvider theme={theme}>
<AppDispatchContext.Provider value={dispatch}>
<AppContext.Provider value={state}>
<Router />
</AppContext.Provider>
</AppDispatchContext.Provider>
</MantineProvider>
)
}
export default function Layout() {
const state = useAppContext()
const dispatch = useAppDispatchContext()
const navigate = useNavigate()
useEffect(() => {
console.log('layout useEffect fired: ', state.redirectTo)
if (state.redirectTo) {
navigate(state.redirectTo)
dispatch({ type: 'REDIRECT_RESET', payload: null })
}
}, [state.redirectTo])
return (
<AppShell>
<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
)
}
If I dispatch 'LOGOUT', useEffect WILL pick up the change to the property redirectTo
:
dispatch({
type: 'LOGOUT',
payload: null
})
If I dispatch 'LOGIN', useEffect
does not pick up the change to the property redirectTo
:
dispatch({
type: 'LOGIN',
payload: {
username,
token,
},
})
Link to sandbox: https://codesandbox.io/p/sandbox/vq2h8m
The main issue you have is that Login
and its route is rendered outside the Layout
route component, so Layout
is not mounted and so its useEffect
hook to check the state.redirectTo
value isn't going to run and effect any navigation change.
A very trivial solution would be to simply move the "/login"
route under the root "/"
layout route.
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{
path: '/',
element: <HomePage />,
},
{
path: '/login',
element: <Login />,
},
],
},
]);
Though I suspect you're not wanting the additional Layout
UI to be rendered when logging in.
The alternative I'd suggest here then is to implement protected routes. This appears to be confirmed by your comment:
I essentially want if there is no token in localstorage and they try to hit any route -> redirect to login page. if they have token in localstorage and they hit /login redirect to / if they hit logout redirect to /login.
Create two route protection components, one to bounce authenticated users from "/login"
to "/"
or "/home"
, and another to bounce unauthenticated users from "/home"
(and any other auth routes) to "/login"
.
First update the reducer state to include the user data, e.g. the token:
interface AppState {
token: null | string;
}
// Initialize token state from localStorage
const initialState = {
token: localStorage.getItem("token")
? JSON.parse(localStorage.getItem("token") ?? "")
: null,
};
function reducer(state: AppState, action: Action): AppState {
console.log("reducer", { state, action });
switch (action.type) {
case "LOGIN":
// Persist token value to localStorage
localStorage.setItem("token", JSON.stringify(action.payload.token));
return {
...state,
token: action.payload.token,
};
case "LOGOUT":
// Remove any stored user data
localStorage.clear();
return { ...state, token: null };
default:
return state;
}
}
const ProtectedRoute = () => {
const state = useAppContext();
return state.token !== null ? <Outlet /> : <Navigate to="/login" replace />;
};
const AnonymousRoute = () => {
const state = useAppContext();
return state.token === null ? <Outlet /> : <Navigate to="/" replace />;
};
Update your routes to wrap the routes you want to protect accordingly.
const router = createBrowserRouter([
{
element: <AnonymousRoute />,
children: [
{
path: "/login",
element: <Login />,
},
],
},
{
element: <Layout />,
children: [
{
index: true,
element: <Navigate to="/home" replace />,
},
{
element: <ProtectedRoute />,
children: [
{
path: "/home",
element: <Home />,
},
],
},
],
},
]);
Update Login
and Home
buttons to dispatch the actions and effect the navigation changes:
function Login() {
const dispatch = useAppDispatchContext();
const navigate = useNavigate();
return (
<>
<h1>Login</h1>
<button
type="button"
onClick={() => {
dispatch({
type: "LOGIN",
payload: {
username: "bill",
token: "t0k3n",
},
});
navigate("/", { replace: true });
}}
>
Log in
</button>
</>
);
}
function Home() {
const dispatch = useAppDispatchContext();
const navigate = useNavigate();
return (
<div>
Home Page
<button
type="button"
onClick={() => {
dispatch({ type: "LOGOUT" });
navigate("/login", { replace: true });
}}
>
Log out
</button>
</div>
);
}
The Layout
component is now only responsible for UI layout and route outlet:
function Layout() {
return (
<div>
<h1>Layout</h1>
<Outlet />
</div>
);
}
Completely unrelated to the routing issue, for improved type safety I recommend you more-strictly type your action objects. Instead of an action object that is any of the type
types and a required payload that is typed as any
, but more explicit.
Update from:
interface Action {
type:
| "LOGIN"
| "LOGOUT"
| ... ;
payload?: any;
}
to something like:
type Action =
| {
type: "LOGIN";
payload: {
token: string | null;
username?: string;
};
}
| {
type: "LOGOUT";
}
| ... ;
Which will allow for your dispatches and reducer cases to better understand and enforce payload values based on the action type
.