SpicyRiceTea
SpicyRiceTea

Reputation: 31

React code is failing to set the global context before rendering

I created a protected route that is meant to call a function getAuth() that:

  1. Sends a JWT to the server so that the user can be authenticated
  2. Upon receiving the user's information from the JWT response, updates the global context to match

The purpose of this protected route is authenticate the user by resetting the global context after each page, and to then use that global context to redirect them to the proper page if they fail to authenticate.

If I didn't use the getAuth() function, and just relied on the global context, when a user would refresh their page, the global context would be lost.

My code for the UserProtected route is the following:

//UserProtecterRoute.js
import { useMemo, useRef, useState } from "react";
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../context/AuthContext";

export default function UserProtectedRoute() {
  const getAuth = useAuth().GetAuth;
  const useAuthState = useAuth().authState;
  var authorized = useRef(false);

  useMemo(() => {
    if (localStorage.getItem("token")) {
      getAuth();
      if (useAuthState.username) {
        authorized.current = true;
      }
    }
  }, []);

  if (authorized.current) {
    return <Outlet />;
  } else {
    return <Navigate to="/Login"></Navigate>;
  }
}

The code for the global authorization context is the following:

//AuthContext.js
import React, { useContext, useState } from "react";
import * as ReactDOM from "react-dom";
import axios from "axios";

const AuthContext = React.createContext();

export function useAuth() {
  // returns a function which once run returns an object with the values you
  // placed in it. In this case it returns {authState,setAuthState,GetAuth}
  // you can call this object.prop to use the prop
  return useContext(AuthContext);
}

export function AuthProvider({ children }) {
  const [authState, setAuthState] = useState({
    id: 0,
    username: "",
    role: "user",
  });

  async function GetAuth() {
    if (localStorage.getItem("token")) {
      await axios
        .get("http://localhost:3001/Auth", {
          headers: {
            accessToken: localStorage.getItem("token"),
          },
        })
        .then((res) => {
          setAuthState((prev) => ({
            ...prev,
            id: res.data.id,
            username: res.data.user,
            role: res.data.role,
          }));
        })
        .catch((error) => {
          console.log(error);
        });
    }
  }

  return (
    <AuthContext.Provider value={{ authState, setAuthState, GetAuth }}>
      {children}
    </AuthContext.Provider>
  );
}

When I run my code to debug, it follows this order:

  1. getAuth() is called
  2. The axios get request is sent in the AuthContext.js
  3. The if(useAuthState.username) statement in the useMemo is run, but useAuthState.username is undefined
  4. The rest of the code in the UserProtected route is run, however, since the AuthContext hasn't updated yet, it redirects to the wrong route
  5. The then() of the AuthContext runs (which is meant to set the global context, but now it's too late)
  6. The axios call from AuthContext runs a second time
  7. The then() of the AuthContext runs again

I believe that the issue has something to do with promises, I tried wrapping the if(localStorage.getItem('token')) in an async function and putting await in from of getAuth, but no luck there either. I'm also suspecting there might be something to do with when setState gets implemented, but my experience in React and Promises is such that I simply can't figure it out by myself.

If someone could guide me to what I'm doing wrong, I would greatly appreciate it.

Upvotes: 2

Views: 368

Answers (1)

Phil
Phil

Reputation: 164766

I would do the following instead...

  1. Trigger the get auth effect within your AuthProvider component on mount. That way you don't have to delegate it to your other components.
  2. Add an extra piece of state to the context to let consumers know it is loading
  3. Rely on the context state to render your authenticated components. There's no need for an extra ref

In your context provider...

// This could be in a separate, testable module
const fetchAuth = async (accessToken) => {
  const {
    data: { id, user, role },
  } = await axios.get("http://localhost:3001/Auth", {
    headers: { accessToken },
  });

  return { id, username: user, role };
};

export function AuthProvider({ children }) {
  const [authState, setAuthState] = useState({
    id: 0,
    username: "",
    role: "user",
  });

  // Loading state
  const [isLoading, setIsLoading] = useState(false);

  // Helper memo
  const isAuthenticated = useMemo(() => !!authState.username, [authState]);

  const getAuth = async () => {
    setIsLoading(true);
    const token = localStorage.getItem("token");
    try {
      if (token) {
        setAuthState(await fetchAuth(token));
      }
    } catch (err) {
      console.error(err.toJSON());
    } finally {
      setIsLoading(false);
    }
  };

  // Call getAuth on mount
  useEffect(() => {
    getAuth();
  }, []);

  return (
    <AuthContext.Provider
      value={{ authState, getAuth, isAuthenticated, isLoading }}
    >
      {children}
    </AuthContext.Provider>
  );
}

and in your component...

//UserProtecterRoute.js
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../context/AuthContext";

export default function UserProtectedRoute() {
  const { isAuthenticated, isLoading } = useAuth();

  if (isLoading) {
    // just an example
    return <p>Loading...</p>;
  }

  if (isAuthenticated) {
    return <Outlet />;
  }

  return <Navigate to="/Login"></Navigate>;
}

Upvotes: 2

Related Questions