Search code examples
javascriptreactjsreduxjwtreact-router-dom

Blank page shows when use navigate and redux in reactJS


I have an issue when use navigate from login page to "/dashboard". When user click to login, they correctly redirect to "/dashboard" but show me a blank page, but when refresh the page everything works correctly.

authSlice.js

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import api from "../../services/api.js";

export const login = createAsyncThunk(
  "auth/login",
  async (payload) => {
    try {
      const response = await api.request({
        url: `/api/account/token/portal/organic`,
        method: "post",
        data: payload,
      });
      return response.data.result;
    } catch (error) {
      console.error("error", error);
      return Promise.reject(error);
    }
  });

const authSlice = createSlice({
  name: "auth",
  initialState: {
    token: JSON.parse(localStorage.getItem("token")) || {},
    isLoading: false,
    hasError: null
  },
  reducers: {
    logout: (state) => {
      state.token = {};
      localStorage.removeItem("token");
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(login.pending, (state) => {
        state.isLoading = true;
        state.hasError = null;
      })
      .addCase(login.fulfilled, (state, action) => {
        state.token = action.payload;
        state.isLoading = false;
        state.hasError = null
        localStorage.setItem("token", JSON.stringify(action.payload));
      })
      .addCase(login.rejected, (state, action) => {
        state.isLoading = false;
        state.hasError = action.error;
      })
  }
});

// Selectors
export const selectToken = state => state.auth.token;
export const selectLoadingState = state => state.auth.isLoading;
export const selectErrorState = state => state.auth.hasError;
export const { logout } = authSlice.actions;
export default authSlice.reducer;

DashboardLayout.jsx

import { Navigate, Outlet } from "react-router-dom";
import {useDispatch, useSelector} from "react-redux";
import { selectToken } from "../store/auth/authSlice.js";
import { logout } from "../store/auth/authSlice.js";

const DashboardLayout = () => {
  const { accessToken } = useSelector(selectToken);
  const dispatch = useDispatch();

  const handleLogout = () => {
    dispatch(logout());
  };

  if (!accessToken) {
    return <Navigate to="/login" />;
  }

  return (
    <>
      <Outlet />
      <button onClick={handleLogout}>
        Logout
      </button>
    </>
  )
}
export default DashboardLayout

LoginPage.jsx

import React, { useEffect, useState } from 'react';
import { Form, Input, Button, Card, Alert, Layout } from 'antd';
import { useDispatch, useSelector } from "react-redux";
import { login, selectErrorState, selectToken } from "../store/auth/authSlice.js";
import { useNavigate } from "react-router-dom";

const LoginPage = () => {
  const [form] = Form.useForm();
  const dispatch = useDispatch();
  const [errorMessage, setErrorMessage] = useState('');
  const error = useSelector(selectErrorState);
  const navigate = useNavigate()

  const onFinish = (values) => {
    dispatch(login(values))
      .then(() => {
        navigate('/dashboard');
      })
      .catch((error) => {
        console.log('Login failed:', error);
      });
  };

  const onFinishFailed = (errorInfo) => {
    console.log('Failed:', errorInfo);
  };

  return (
    <Layout
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        height: '100vh',
      }}
    >
      <Card
        title="Login"
        style={{
          width: 480,
        }}
      >
        {error && (
          <Alert message={error.message} type="error" showIcon closable />
        )}
        <Form
          form={form}
          name="basic"
          labelCol={{
            span: 6,
          }}
          wrapperCol={{
            span: 17,
          }}
          initialValues={{
            remember: true,
          }}
          onFinish={onFinish}
          onFinishFailed={onFinishFailed}
          autoComplete="off"
        >
          <Form.Item
            label="Username"
            name="Username"
            rules={[
              {
                required: true,
                message: 'Please input your username!',
              },
            ]}
          >
            <Input />
          </Form.Item>

          <Form.Item
            label="Password"
            name="Password"
            rules={[
              {
                required: true,
                message: 'Please input your password!',
              },
            ]}
          >
            <Input.Password />
          </Form.Item>

          <Form.Item
            wrapperCol={{
              offset: 8,
              span: 16,
            }}
          >
            <Button type="primary" htmlType="submit">
              Login
            </Button>
          </Form.Item>
        </Form>
      </Card>
    </Layout>
  );
};

export default LoginPage;

Route.js

import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { useSelector } from "react-redux";
import { selectToken } from "../store/auth/authSlice.js";
import DashboardLayout from "../template/DashboardLayout.jsx";
import LoginPage from "../pages/Login.jsx";

const Routes = () => {
  const { accessToken } = useSelector(selectToken);

  // Define public routes accessible to all users
  const routesForPublic = [
    {
      path: "/service",
      element: <div>Service Page</div>,
    },
    {
      path: "/about-us",
      element: <div>About Us</div>,
    },
  ];

  // Define routes accessible only to authenticated users
  const routesForAuthenticatedOnly = [
    {
      path: "/dashboard",
      element: <DashboardLayout />, // Wrap the component in ProtectedRoute
      children: [
        {
          path: "",
          element: <h1>Dashboard</h1>,
        },
      ],
    },
  ];

  // Define routes accessible only to non-authenticated users
  const routesForNotAuthenticatedOnly = [
    {
      path: "/",
      element: <div>Home Page</div>,
    },
    {
      path: "/login",
      element: <LoginPage />,
    },
  ];

  // Combine and conditionally include routes based on authentication status
  const router = createBrowserRouter([
    ...routesForPublic,
    ...(!accessToken ? routesForNotAuthenticatedOnly : []),
    ...routesForAuthenticatedOnly,
  ]);

  // Provide the router configuration using RouterProvider
  return <RouterProvider router={router} />;
};

export default Routes;

store.js

import { combineReducers, configureStore } from "@reduxjs/toolkit";
import authReducer from "./auth/authSlice.js";

const rootReducer = combineReducers({
  auth: authReducer,
});

const store = configureStore({
  reducer: rootReducer,
});

export default store;

api.js

import axios from "axios";

const token = localStorage.getItem("token");
const baseURL = import.meta.env.VITE_API_BASE_URL;
const api = axios.create({
  baseURL
});

api.interceptors.request.use(
  (config) => {
    if (token) {
      const { accessToken } = JSON.parse(token);
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    // Do something before request is sent
    return config;
  },
  (error) => {
    // Do something with request error
    return Promise.reject(error);
  }
);

// Add a response interceptor
api.interceptors.response.use(
  (response) => {
    // Do something with response data
    return response;
  },
  (error) => {
    // Do something with response error
    return Promise.reject(error);
  }
);

export default api;

I try to navigate to "/dashboard" without refresh but not work and renders a blank page.

  1. /login -> for login use any user and password (i use simple mock data)
  2. /dashboard -> for private route
  3. After press login see your localStorage.

Run on codeSandBox


Solution

  • Issues

    The main issue is that the router is re-declared each time the Routes component is rerendered, e.g. like when the accessToken state is updated. This interrupts the React rendering cycle and creates a mismatch between the URL which updated correctly and the rendered content.

    There were also two routes declared for the same "/" path. There should only ever be one route per path to be matched.

    An additional issue is that your restricted routes are conditionally rendered, so there exists the possibility they are not mounted when you expect them to be.

    Solution

    • Update the routing configuration to declare all routes and routers outside the React tree. This is so the app has a stable router context to handle all routing and navigation.
    • Implement route protection that checks the accessToken when the route is being accessed, not prior than that to render the route.

    Example:

    src/routes/index.jsx

    import { Navigate, Outlet } from "react-router-dom";
    import { useSelector } from "react-redux";
    import { selectToken } from "../store/auth/authSlice.js";
    
    const PrivateRoutes = () => {
      const { accessToken } = useSelector(selectToken);
    
      return accessToken
        ? <Outlet />
        : <Navigate to="/login" replace />;
    };
    
    const AnonymousRoutes = () => {
      const { accessToken } = useSelector(selectToken);
    
      return accessToken
        ? <Navigate to="/dashboard" replace />
        : <Outlet />;
    };
    
    // Define public routes accessible to all users
    const routesForPublic = [
      {
        path: "/service",
        element: <div>Service Page</div>,
      },
      {
        path: "/about-us",
        element: <div>About Us</div>,
      },
    ];
    
    // Define routes accessible only to authenticated users
    const routesForAuthenticatedOnly = [
      {
        element: <DashboardLayout />,
        children: [
          {
            path: "dashboard",
            element: <h1>Dashboard</h1>,
          },
        ],
      },
    ];
    
    // Define routes accessible only to non-authenticated users
    const routesForNotAuthenticatedOnly = [
      {
        path: "/",
        element: <div>Home Page</div>,
      },
      {
        path: "/login",
        element: <LoginPage />,
      },
    ];
    
    // Combine and conditionally include routes based on authentication status
    const router = createBrowserRouter([
      // Anyone can access these routes
      ...routesForPublic,
    
      // Only authenticated users can access these routes
      {
        element: <PrivateRoutes />,
        children: routesForAuthenticatedOnly
      },
    
      // Only unauthenticated users can access these routes
      {
        element: <AnonymousRoutes />,
        children: routesForNotAuthenticatedOnly
      },
    ]);
    
    const Routes = () => {
      return <RouterProvider router={router}/>;
    };
    
    export default Routes;
    

    Additionally, the Login component should unwrap the resolved result of the asynchronous login thunk. This is because all createAsyncThunk actions resolve, but you need to unwrap them to know if they were successful or if there was an error. See Handling Thunk Results for more details.

    const onFinish = (values) => {
      dispatch(login(values)).unwrap()
        .then(() => {
          navigate('/dashboard');
        })
        .catch((error) => {
          console.log('Login failed:', error);
        });
    };
    

    or

    const onFinish = async (values) => {
      try {
        await dispatch(login(values)).unwrap();
        navigate('/dashboard');
      } catch(error) {
        console.log('Login failed:', error);
      }
    };