My setup: React 18.1, React Router DOM 6.3, and React Redux 18.0.2.
My goal: If user is not authenticated (via an in-memory JWT) the redirect to login page. Upon successful auth, redirect user to the route they were originally trying to reach.
My question: Based on prior Stack Overflow answers that suggested passing the router history object (which I think it the correct solution for prior versions of React Router), I decided to pass the object returned by the useNavigate() hook. With the exception of a serialization error message (which I think I can disable according to this post), it seems to work. But will this be problematic? Bad practice?
Login.tsx
import React, { useState } from "react";
import Form from "react-bootstrap/Form";
import Button from "react-bootstrap/Button";
import "./Login.css";
import { authenticate } from "../features/auth/authSlice";
import { store } from "../app/store";
import { useLocation, useNavigate } from "react-router-dom";
interface LocationState {
target_url: string | null;
}
const Login: React.FC = (props) => {
const [email, setEmail] = useState("[email protected]");
const [password, setPassword] = useState("password");
const location = useLocation();
const navigate = useNavigate();
const { target_url } = location.state as LocationState || { target_url: null};
function validateForm() {
return email.length > 0 && password.length > 0;
}
function handleSubmit(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault();
store.dispatch(authenticate({
email: email,
password: password,
navigate: navigate,
target_url: target_url
}));
}
return (
<div className="Login">
<Form>
<Form.Group controlId="email">
<Form.Label>Email</Form.Label>
<Form.Control
autoFocus
type="email"
value={email}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
/>
</Form.Group>
<Form.Group controlId="password">
<Form.Label>Password</Form.Label>
<Form.Control
type="password"
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
/>
</Form.Group>
<Button
size="lg"
type="submit"
disabled={!validateForm()}
onClick={handleSubmit}>
Login
</Button>
</Form>
</div>
);
}
export default Login;
authSlice.ts
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { API } from "../../app/services";
export type User = {
email: string,
}
export interface AuthState {
user: User | null,
access: string | null
}
const initialState: AuthState = {
user: null,
access: null,
}
export interface IAuthArgs {
email: string,
password: string,
navigate: any,
target_url: string | null,
}
export interface IAuthRespPayload {
success: boolean,
access: string | null,
navigate: any,
target_url: string | null,
}
export const authenticate = createAsyncThunk(
'auth/authenticate',
async (args: IAuthArgs, thunkAPI) => {
const response = await API.authenticate(args.email, args.password);
console.log(response);
return {
success: true,
access: response.access,
navigate: args.navigate,
target_url: args.target_url
}
}
)
export const authSlice = createSlice({
name: 'authenticator',
initialState,
reducers: {
logout: (state, action) => {
state.access = null;
state.user = null;
}
},
extraReducers: (builder) => {
builder
.addCase(authenticate.fulfilled, (state, action: PayloadAction<IAuthRespPayload>) => {
state.access = action.payload.access;
if (action.payload.target_url !== null)
action.payload.navigate(action.payload.target_url);
})
.addCase(authenticate.rejected, (state, action) => {
// TODO
console.log(action.error.message);
})
.addCase(authenticate.pending, (state, action) => {
// TODO
})
},
});
const { actions, reducer } = authSlice;
export const { logout } = actions;
export default authSlice.reducer;
CodePudding user response:
That should/could work, it's one of the ways to handle it. An alternative is to create a global history reference and access this in asynchronous redux code, see React-router-dom v6 navigate outside of components.
The other alternative, and the easiest, is to simply handle the navigation action in the component dispatching the authentication action and completely avoid needing to create any custom history object and/or pass a reference to it or the navigate
function in redux actions.
This is good for a few reasons:
- Separations of concerns. Don't mix authentication logic with navigation logic.
- Reducers are to be considered pure functions that update state, i.e. a pure function of the previous state and an action that only computes the next state value. There shouldn't be side-effects like navigating or API calls, etc. If you keep the navigation in the redux code it should be kept in the asynchronous
authenticate
function (i.e. wheredispatch
can be called), not passed on to a reducer function.
Example:
import { useDispatch } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
const Login: React.FC = (props) => {
const dispatch = useDispatch();
const location = useLocation();
const navigate = useNavigate();
const [email, setEmail] = useState("[email protected]");
const [password, setPassword] = useState("password");
const { target_url } = location.state as LocationState || { target_url: null};
...
function handleSubmit(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault();
dispatch(authenticate({ email, password }))
.then(() => {
navigate(target_url, { replace: true });
});
}
...
}
...
export interface IAuthArgs {
email: string,
password: string,
}
export interface IAuthRespPayload {
success: boolean,
access: string | null,
}
export const authenticate = createAsyncThunk(
'auth/authenticate',
async (args: IAuthArgs, thunkAPI) => {
const response = await API.authenticate(args.email, args.password);
console.log(response);
return {
success: true,
access: response.access,
}
}
);
export const authSlice = createSlice({
name: 'authenticator',
initialState,
reducers: {
logout: (state, action) => {
state.access = null;
state.user = null;
}
},
extraReducers: (builder) => {
builder
.addCase(authenticate.fulfilled, (state, action: PayloadAction<IAuthRespPayload>) => {
state.access = action.payload.access;
state.success = action.payload.success;
})
.addCase(authenticate.rejected, (state, action) => {
// TODO
console.log(action.error.message);
})
.addCase(authenticate.pending, (state, action) => {
// TODO
})
},
});