React context provider updates state after context consumer renders

Viewed 50

I am trying to implement Protected Routes in my app. I am using cookie-based session authentication. The issue is: Whenever I try to access a protected page for the first time, the RequireAuth component has the isAuthenticated value as false and hence it navigates to /. From the console logs, I can see Inside require auth. before Inside provide auth..

Questions:

  1. Is using useEffect in the context provider the right way to set the auth state?
  2. How do I make sure that the context provider state is set before accessing the context in the consumer RequireAuth?

I have a context provider ProvideAuth which makes an API call to check if the user is already authenticated.


const authContext = createContext();

export function ProvideAuth({ children }) {
    const navigate = useNavigate();
    const location = useLocation();
    const [isAuthenticated, setIsAuthenticated] = useState(false);
    const [userInfo, setUserInfo] = useState({});
    
    const fetchData = async () => {
        const isAuthenticated = await CheckAuthentication();
        setIsAuthenticated(isAuthenticated);
        if (isAuthenticated) {
            const userInfo = await GetUserInfo();
            setUserInfo(userInfo);
        }
    }

    useEffect(() => {
        console.log("Inside provide auth. " + isAuthenticated + " " + location.pathname);
        fetchData();
    }, []);

    const value = {
        isAuthenticated,
        userInfo
    };

    return <authContext.Provider value={value}>{children}</authContext.Provider>;
}

Auth context consumer

export const useAuth = () => {
    return useContext(authContext);
};

I use the context in a RequireAuth component to check if the user is already authenticated and redirect if not.

export default function RequireAuth({ children }) {
    const { isAuthenticated, userInfo } = useAuth();
    const location = useLocation();

    useEffect(() => {
        console.log("Inside require auth. " + isAuthenticated + " " + location.pathname);
    }, []);

    return isAuthenticated === true ?
        (children ? children : <Outlet />) : 
        <Navigate to="/" replace state={{ from: location }} />;
}

The context provider is used in the App.js

return (
    <ProvideAuth>
      <div className='App'>
        <Routes>
          <Route exact path="/" element={<Home />} />
          <Route path="/pricing" element={<Pricing />} />
          <Route element={<RequireAuth /> }>
            <Route path="/jobs" element={<Jobs />} >
              <Route index element={<MyJobs />} />
              <Route path="new" element={<NewJob />} />
              <Route path=":jobId" element={<JobDetails />} />
              <Route path=":jobId/stats" element={<JobStats />} />
            </Route>
          </Route>
          <Route path="*" element={<NotFound />} />
        </Routes>
      </div>
    </ProvideAuth>
  );

2 Answers

What you can do is check, If the request is processed or not. If processing show loader if any error shows some error msg or redirect. If everything is fine load provider.

const authContext = createContext();

export function ProvideAuth({ children }) {
  const [state, setState] = useState({
    user: null,
    isAuthenticated: false,
    isLoading: false,
    error: null,
  });

  useEffect(() => {
    const fetchData = async () => {
      try {
        const isAuthenticated = await CheckAuthentication();
        if (isAuthenticated) {
          const user = await GetUserInfo();
          setState((prev) => ({ ...prev, isAuthenticated, user }));
        }
      } catch (error) {
        setState((prev) => ({ ...prev, error }));
      } finally {
        setState((prev) => ({ ...prev, isLoading: false }));
      }
    };
    fetchData();
  }, []);

  if (state.isLoading) return <Loading />;
  if (state.error) return <ErrorMessage error={state.error} />;
  return <authContext.Provider value={state}>{children}</authContext.Provider>;
}

That's because that useEffect in ProvideAuth is as any useEffect an asynchronous task, which means the component and its children may render before its callback gets executed.

A solution is to set up a loading state in ProvideAuth, called for example isCheckingAuth, set to true by default, and to false after you have done all the fetching. And you pass it down to RequireAuth, like so :

const authContext = createContext();

export function ProvideAuth({ children }) {
    const navigate = useNavigate();
    const location = useLocation();
    const [isCheckingAuth, setIsCheckingAuth] = useState(true);
    const [isAuthenticated, setIsAuthenticated] = useState(false);
    const [userInfo, setUserInfo] = useState({});
    
    const fetchData = async () => {
        const isAuthenticated = await CheckAuthentication();
        setIsAuthenticated(isAuthenticated);
        if (isAuthenticated) {
            const userInfo = await GetUserInfo();
            setUserInfo(userInfo);
        }
        setIsCheckingAuth(false)
    }

    useEffect(() => {
        console.log("Inside provide auth. " + isAuthenticated + " " + location.pathname);
        fetchData();
    }, []);

    const value = {
        isAuthenticated,
        userInfo,
        isCheckingAuth
    };

    return <authContext.Provider value={value}>{children}</authContext.Provider>;
}

You use that isCheckingAuth inRequireAuth to show a loader while the fetching is being done, this way:

export default function RequireAuth({ children }) {
    const { isAuthenticated, userInfo, isCheckingAuth } = useAuth();
    const location = useLocation();
    
     useEffect(() => {
       if(isCheckingAuth) return;
       console.log("Inside require auth. " + isAuthenticated + " " + location.pathname);
     }, [isCheckingAuth]);
    
    if(isCheckingAuth) return <div>Loading...</div>

    return isAuthenticated === true ?
        (children ? children : <Outlet />) : 
        <Navigate to="/" replace state={{ from: location }} />;
}
Related