Home > Blockchain >  Passing useNavigate() object to async handler
Passing useNavigate() object to async handler

Time:06-07

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:

  1. Separations of concerns. Don't mix authentication logic with navigation logic.
  2. 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. where dispatch 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
    })
  },
});
  • Related