Search code examples
reactjstypescriptauthenticationreact-hooksreact-custom-hooks

Component Rendering Before useState Update


I'm encountering an issue in my React application where I make an API call to a sign-in endpoint. The response from the API returns correctly, indicating successful authentication. However, I'm facing two problems:

The apiData and isAuth states are not updating in time, causing the navigation to the sign-in page to occur before these states are updated. Consequently, the application navigates to the sign-in page too quickly, without waiting for the state updates.

useApi hook

import axios, { AxiosResponse } from "axios";
import { useEffect, useRef, useState } from "react";

const useApi = <T,>(url: string, method: string, payload?: T) => {
  const [apiData, setApiData] = useState<AxiosResponse>();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState();

  useEffect(() => {
    const getData = async () => {
      try {
        const response = await axios.request({
          params: payload,
          method,
          url,
        });

        setApiData(response.data);
      } catch (e) {
        setError(e.message);
      } finally {
        setLoading(true);
      }
    };

    getData();
  }, []);

  return { apiData, loading, error };
};

export default useApi;

this is the form

import { useState } from "react";
import { useNavigate } from "react-router-dom";

export const SignIn = () => {
  const [userInfo, setUserInfo] = useState({
    user: "",
    password: "",
  });

  const navigate = useNavigate();

  const submitForm = (e) => {
    e.preventDefault();
    navigate("/user/sign-in", { state: userInfo });
  };

  const updateUser = (newVal: string) => {
    setUserInfo((prev) => ({
      ...prev,
      user: newVal,
    }));
  };

  const updatePassword = (newVal: string) => {
    setUserInfo((prev) => ({
      ...prev,
      password: newVal,
    }));
  };

  return (
    <form onSubmit={(e) => submitForm(e)}>
      <label>username</label>
      <input type="text" onChange={(e) => updateUser(e.target.value)} />

      <label htmlFor="">password</label>
      <input type="password" onChange={(e) => updatePassword(e.target.value)} />

      <button type="submit">submit</button>
    </form>
  );
};

privateRoute where I check if user is auth or not

import { useEffect, useState } from "react";
import useApi from "../../api/useApi";
import { Navigate, useLocation } from "react-router-dom";
import UserDashboard from "./UserDashboard";

export const PrivateRoute = () => {
  const location = useLocation();

  const { apiData, loading, error } = useApi(
    import.meta.env.VITE_SIGN_IN,
    "GET",
    location.state,
  );

  const [isAuth, setAuth] = useState<boolean>(false);

  useEffect(() => {
    if (apiData?.status === 200) {
      console.log(apiData, "api apiData");
      setAuth(true);
    }
  }, [apiData]);

  return (
    <>
      {isAuth ? (
        <UserDashboard />
      ) : (
        <Navigate to="/sign-in" state={{ from: location.pathname }} />
      )}
    </>
  );
};


Solution

  • The initial isAuth state, false, matches the confirmed-later unauthenticated value false, so on the initial render cycle the navigation action to the login page is effected. The useEffect hook runs at the end of the render cycle.

    Use an initial isAuth state value that is neither true for authenticated nor false for unauthenticated, e.g. you need a third value to indicate "unknown" status. Use this third value to conditionally render null or any sort of loading indicator while the authentication check runs and confirms the user's status.

    export const PrivateRoute = () => {
      const location = useLocation();
    
      const { apiData, loading, error } = useApi(
        import.meta.env.VITE_SIGN_IN,
        "GET",
        location.state,
      );
    
      const [isAuth, setAuth] = useState<boolean | undefined>(undefined);
    
      useEffect(() => {
        if (apiData) {
          console.log(apiData, "api apiData");
          setAuth(apiData.status === 200);
        }
      }, [apiData]);
    
      if (isAuth === undefined) {
        return null; // or loading spinner/indicator/etc
      }
    
      return isAuth
        ? <UserDashboard />
        : (
          <Navigate
            to="/sign-in"
            state={{ from: location.pathname }}
            replace
          />
        );
    };
    

    The isAuth value appears to be a derived value, so it doesn't actually need to be a React state at all, and is actually generally considered to be a React anti-pattern doing so. You could just compute isAuth directly from the apiData.

    export const PrivateRoute = () => {
      const location = useLocation();
    
      const { apiData, loading, error } = useApi(
        import.meta.env.VITE_SIGN_IN,
        "GET",
        location.state,
      );
    
      // undefined | true | false
      const isAuth = apiData && apiData.status === 200;
    
      if (isAuth === undefined) {
        return null; // or loading spinner/indicator/etc
      }
    
      return isAuth
        ? <UserDashboard />
        : (
          <Navigate
            to="/sign-in"
            state={{ from: location.pathname }}
            replace
          />
        );
    };