javascriptreactjsrtk-query

bug when redirect user when token expired in react js


I try to redirect user when user are not authenticate specifically, when token is expired, but when user redirected to login page, if user want login again they have to click handlesubmit login twice to log in.

How when user only just click once to login?

login code:

import LoginPageView from "./view";
import * as Yup from "yup";
import { useLoginMutation } from "../../store/service";
import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { setItem } from "../../utils";

const LoginPage = () => {
  const [login, result] = useLoginMutation();
  const [showPassword, setShowPassword] = useState(false);
  const navigate = useNavigate();

  const loginSchema = Yup.object().shape({
    email: Yup.string().email("Invalid email").required("Email Required"),
    password: Yup.string().required("Password Required"),
  });

  const initialValues = {
    email: "",
    password: "",
  };

  async function handleSubmit(values) {
    try {
      await login(values).unwrap();
      toast.success("Login Successfully");
    } catch (error) {
      result.reset();
      toast.error(error.data?.message || "Login failed");
    }
  }

  function handleToggleShowPassword() {
    setShowPassword((prev) => !prev);
  }

  useEffect(() => {
    if (result?.isSuccess) {
      const { role, token } = result?.data?.data || {};

      // set to local storage
      setItem("token", token);

      if (role === "user") {
        navigate("/explore");
      } else if (role === "admin") {
        navigate("/dashboard");
      }
    }
  }, [result?.isSuccess, navigate]);

  useEffect(() => {
    if (result?.isError) {
      console.log(result.error);
    }
  }, [result?.isError]);

  useEffect(() => {
    return () => {
      result.reset();
    };
  }, []);

  return (
    <LoginPageView
      initialValues={initialValues}
      validationOnLogin={loginSchema}
      onSubmit={handleSubmit}
      onShowPassword={handleToggleShowPassword}
      showPassword={showPassword}
    />
  );
};

export default LoginPage;

Route protected component code:

import { Navigate, Outlet } from "react-router-dom";
import { useGetCurrentUserApiQuery } from "../../store/service";
import { removeItem } from "../../utils";

export function ProtectedRoute({ statusRole }) {
  const { data, isError, error } = useGetCurrentUserApiQuery();

  if (isError && error?.status === 401) {
    removeItem("token")
    return <Navigate to={"/"} />;
  }

  if (data && data?.data?.role === statusRole) {
    return <Outlet />;
  } 
}

Route configuration:

import { createBrowserRouter } from "react-router-dom";

import { ProtectedRoute } from "./protected-route";
import React, { Suspense } from "react";
import { Spinner } from "../components/ui";

const LoginPage = React.lazy(() => import("../pages/login"));
const ExplorePage = React.lazy(() => import("../pages/users/explore"));
const DashboardAdminPage = React.lazy(() => import("../pages/admin/dashboard"));
const SettingPage = React.lazy(() => import("../pages/users/setting"));
const ViewAllProductsPage = React.lazy(() =>
  import("../pages/users/products/view-all")
);

const UpdateProductPage = React.lazy(() =>
  import("../pages/admin/products/update")
);
const CreateProductPage = React.lazy(() =>
  import("../pages/admin/products/create")
);
const ViewAllProductsPageAdmin = React.lazy(() =>
  import("../pages/admin/products/view-all")
);

const router = createBrowserRouter([
  {
    path: "/",
    element: (
      <Suspense fallback={<Spinner />}>
        <LoginPage />
      </Suspense>
    ),
  },

  {
    path: "/",
    element: <ProtectedRoute statusRole={"user"} />,
    children: [
      {
        path: "/explore",
        element: (
          <Suspense fallback={<Spinner />}>
            <ExplorePage />
          </Suspense>
        ),
      },
      {
        path: "/setting",
        element: (
          <Suspense fallback={<Spinner />}>
            <SettingPage />
          </Suspense>
        ),
      },
      {
        path: "/products",
        element: (
          <Suspense fallback={<Spinner />}>
            <ViewAllProductsPage />
          </Suspense>
        ),
      },
    ],
  },

  {
    path: "/",
    element: <ProtectedRoute statusRole={"admin"} />,
    children: [
      {
        path: "/dashboard",
        element: (
          <Suspense fallback={<Spinner />}>
            {/* overview */}
            <DashboardAdminPage />
          </Suspense>
        ),
      },
      {
        path: "/dashboard/products",
        element: (
          <Suspense fallback={<Spinner />}>
            <ViewAllProductsPageAdmin />
          </Suspense>
        ),
      },
      {
        path: "/products/add",
        element: (
          <Suspense fallback={<Spinner />}>
            <CreateProductPage />
          </Suspense>
        ),
      },
      {
        path: "/products/edit/:id",
        element: (
          <Suspense fallback={<Spinner />}>
            <UpdateProductPage />
          </Suspense>
        ),
      },
    ],
  },

  {
    path: "*",
    element: <p>Page not found</p>,
  },
]);

export default router; import { createBrowserRouter } from "react-router-dom";

import { ProtectedRoute } from "./protected-route";
import React, { Suspense } from "react";
import { Spinner } from "../components/ui";

const LoginPage = React.lazy(() => import("../pages/login"));
const ExplorePage = React.lazy(() => import("../pages/users/explore"));
const DashboardAdminPage = React.lazy(() => import("../pages/admin/dashboard"));
const SettingPage = React.lazy(() => import("../pages/users/setting"));
const ViewAllProductsPage = React.lazy(() =>
  import("../pages/users/products/view-all")
);

const UpdateProductPage = React.lazy(() =>
  import("../pages/admin/products/update")
);
const CreateProductPage = React.lazy(() =>
  import("../pages/admin/products/create")
);
const ViewAllProductsPageAdmin = React.lazy(() =>
  import("../pages/admin/products/view-all")
);

const router = createBrowserRouter([
  {
    path: "/",
    element: (
      <Suspense fallback={<Spinner />}>
        <LoginPage />
      </Suspense>
    ),
  },

  {
    path: "/",
    element: <ProtectedRoute statusRole={"user"} />,
    children: [
      {
        path: "/explore",
        element: (
          <Suspense fallback={<Spinner />}>
            <ExplorePage />
          </Suspense>
        ),
      },
      {
        path: "/setting",
        element: (
          <Suspense fallback={<Spinner />}>
            <SettingPage />
          </Suspense>
        ),
      },
      {
        path: "/products",
        element: (
          <Suspense fallback={<Spinner />}>
            <ViewAllProductsPage />
          </Suspense>
        ),
      },
    ],
  },

  {
    path: "/",
    element: <ProtectedRoute statusRole={"admin"} />,
    children: [
      {
        path: "/dashboard",
        element: (
          <Suspense fallback={<Spinner />}>
            {/* overview */}
            <DashboardAdminPage />
          </Suspense>
        ),
      },
      {
        path: "/dashboard/products",
        element: (
          <Suspense fallback={<Spinner />}>
            <ViewAllProductsPageAdmin />
          </Suspense>
        ),
      },
      {
        path: "/products/add",
        element: (
          <Suspense fallback={<Spinner />}>
            <CreateProductPage />
          </Suspense>
        ),
      },
      {
        path: "/products/edit/:id",
        element: (
          <Suspense fallback={<Spinner />}>
            <UpdateProductPage />
          </Suspense>
        ),
      },
    ],
  },

  {
    path: "*",
    element: <p>Page not found</p>,
  },
]);

export default router;

Base url configuration:

import { fetchBaseQuery } from "@reduxjs/toolkit/query";
import { getItem } from "../utils";

export const defaultBaseQuery = fetchBaseQuery({
  baseUrl: import.meta.env.VITE_API_URL,
});

export const authenticatedBaseQuery = fetchBaseQuery({
  baseUrl: import.meta.env.VITE_API_URL,
  prepareHeaders: async (headers) => {

   //get token from local storage
    const token = getItem("token");

    if (!headers.get("Authorization")) {
      headers.set("Authorization", `Bearer ${await token}`);
    }

    if (!headers.get("Accept")) {
      headers.set("Accept", "application/json");
    }
    return headers;
  },
});

Auth enpoint rest api

import { createApi } from "@reduxjs/toolkit/query/react";
import { authenticatedBaseQuery } from "../../../config";

export const UserApi = createApi({
  reducerPath: "UserApi",
  baseQuery: authenticatedBaseQuery,
  refetchOnMountOrArgChange: true,
  tagTypes: ["User"],
  endpoints: (build) => ({
    getCurrentUserApi: build.query({
      query: () => `users/currentUser`,
      providesTags: ["User"],
    }),
    updateProfileApi: build.mutation({
      query: (image) => ({
        url: `users/changeProfile`,
        method: "PUT",
        body: image,
      }),
      invalidatesTags: ["User"],
    }),
    updatePasswordApi: build.mutation({
      query: (payload) => ({
        url: `users/changePassword`,
        method: "PUT",
        body: payload,
      }),
      invalidatesTags: ["User"],
    }),
  }),
});

export const {
  useGetCurrentUserApiQuery,
  useUpdateProfileApiMutation,
  useUpdatePasswordApiMutation,
} = UserApi;

Solution

  • The issue you're encountering is likely due to the asynchronous nature of the useEffect and handleSubmit functions. When the token expires and the user is redirected to the login page, the result.reset() in your handleSubmit function might cause a delay or reset some state prematurely, which could require the user to click the login button twice.

    Here's a solution to ensure that the login process only requires a single click:

    Avoid resetting the mutation result inside the handleSubmit function. Instead, reset the result only when the component unmounts.

    Remove redundant useEffect for error handling. If you’re already handling errors inside handleSubmit, there's no need to have another useEffect for it.

    Use synchronous state updates. Ensure that state updates related to authentication are handled in a way that doesn't interfere with the user flow.

    Here’s the revised code:

    import LoginPageView from "./view";
    import * as Yup from "yup";
    import { useLoginMutation } from "../../store/service";
    import { toast } from "react-toastify";
    import { useNavigate } from "react-router-dom";
    import { useEffect, useState } from "react";
    import { setItem } from "../../utils";
    
    const LoginPage = () => {
      const [login, result] = useLoginMutation();
      const [showPassword, setShowPassword] = useState(false);
      const navigate = useNavigate();
    
      const loginSchema = Yup.object().shape({
        email: Yup.string().email("Invalid email").required("Email Required"),
        password: Yup.string().required("Password Required"),
      });
    
      const initialValues = {
        email: "",
        password: "",
      };
    
      async function handleSubmit(values) {
        try {
          await login(values).unwrap(); // Execute login mutation
          toast.success("Login Successfully");
        } catch (error) {
          toast.error(error.data?.message || "Login failed");
        }
      }
    
      function handleToggleShowPassword() {
        setShowPassword((prev) => !prev);
      }
    
      useEffect(() => {
        if (result.isSuccess) {
          const { role, token } = result?.data?.data || {};
    
          // Store token in local storage
          setItem("token", token);
    
          // Navigate based on the role
          if (role === "user") {
            navigate("/explore");
          } else if (role === "admin") {
            navigate("/dashboard");
          }
        }
      }, [result.isSuccess, navigate]);
    
      useEffect(() => {
        return () => {
          result.reset(); // Reset result when the component unmounts
        };
      }, [result]);
    
      return (
        <LoginPageView
          initialValues={initialValues}
          validationOnLogin={loginSchema}
          onSubmit={handleSubmit}
          onShowPassword={handleToggleShowPassword}
          showPassword={showPassword}
        />
      );
    };
    
    export default LoginPage;
    

    Protected Route Code No changes are necessary for the ProtectedRoute component, as it is functioning correctly to handle expired tokens by redirecting the user.

    Explanation:

    Resetting Result on Unmount: By resetting the mutation result only when the component unmounts, you prevent the premature clearing of states that might lead to the double-click issue.

    Handling useEffect Correctly: The success state (result.isSuccess) triggers navigation only once when the login is successful, making sure the token and role-based navigation happen smoothly.

    This should ensure that the user only needs to click the login button once.