reactjstypescriptreact-hooksreact-router

Inconsistent behavior from useEffect


I cannot figure out why useEffect WILL pick up the change to the state property redirectTo when I dispatch 'LOGOUT' but it will NOT pickup the change to redirectTo when I dispatch 'LOGIN'.

anytime a redirect happense I make sure to reset the redirectTo prop to null by dispatching 'REDIRECT_RESET'

Can anyone spot why I am getting inconsistent behavior from useEffect?

Please let me know if I missed any code that would help illustrate the problem.

export function reducer(state: AppState, action: Action): AppState {
  switch (action.type) {
    case 'LOGIN': 
      setStorageItem('snc-user-username', JSON.stringify(action.payload.username))
      setStorageItem('snc-user-token', JSON.stringify(action.payload.token))

      return {
        ...state,
        user: { ...state.user, name: action.payload.username },
        redirectTo: '/',
        token: action.payload.token,
      }
    

    case 'LOGOUT':
      removeStorageItem('snc-user-username')
      removeStorageItem('snc-user-token')

      return { ...state, user: { name: '', email: '', id: '' }, redirectTo: '/login', token: '' }

    case 'REDIRECT_RESET':
      return { ...state, redirectTo: null }

    default:
      return state
  }

}

const router = createBrowserRouter([
  {
    path: '/login',
    element: <Login />,
  },
  {
    path: '/',
    element: <Layout />,
    children: [
      {
        path: '/',
        element: <HomePage />,
      }
    ],
  },
])

export function Router() {
  return <RouterProvider router={router} />
}

export default function App() {
  const [state, dispatch] = useReducer(reducer, initialState)

  return (
    <MantineProvider theme={theme}>
      <AppDispatchContext.Provider value={dispatch}>
        <AppContext.Provider value={state}>
          <Router />
        </AppContext.Provider>
      </AppDispatchContext.Provider>
    </MantineProvider>
  )
}

export default function Layout() {
  const state = useAppContext()
  const dispatch = useAppDispatchContext()
  const navigate = useNavigate()

  useEffect(() => {
    console.log('layout useEffect fired: ', state.redirectTo)

    if (state.redirectTo) {
      navigate(state.redirectTo)
      dispatch({ type: 'REDIRECT_RESET', payload: null })
    }
  }, [state.redirectTo])

  return (
    <AppShell>
      <AppShell.Main>
        <Outlet />
      </AppShell.Main>
    </AppShell>
  )
}

If I dispatch 'LOGOUT', useEffect WILL pick up the change to the property redirectTo:

dispatch({ 
  type: 'LOGOUT', 
  payload: null 
})

If I dispatch 'LOGIN', useEffect does not pick up the change to the property redirectTo:

dispatch({
  type: 'LOGIN',
  payload: {
    username,
    token,
  },
})

Link to sandbox: https://codesandbox.io/p/sandbox/vq2h8m


Solution

  • Issue

    The main issue you have is that Login and its route is rendered outside the Layout route component, so Layout is not mounted and so its useEffect hook to check the state.redirectTo value isn't going to run and effect any navigation change.

    Solution Suggestion

    Trivial

    A very trivial solution would be to simply move the "/login" route under the root "/" layout route.

    const router = createBrowserRouter([
      {
        path: '/',
        element: <Layout />,
        children: [
          {
            path: '/',
            element: <HomePage />,
          },
          {
            path: '/login',
            element: <Login />,
          },
        ],
      },
    ]);
    

    Recommended

    Though I suspect you're not wanting the additional Layout UI to be rendered when logging in.

    The alternative I'd suggest here then is to implement protected routes. This appears to be confirmed by your comment:

    I essentially want if there is no token in localstorage and they try to hit any route -> redirect to login page. if they have token in localstorage and they hit /login redirect to / if they hit logout redirect to /login.

    Create two route protection components, one to bounce authenticated users from "/login" to "/" or "/home", and another to bounce unauthenticated users from "/home" (and any other auth routes) to "/login".

    First update the reducer state to include the user data, e.g. the token:

    interface AppState {
      token: null | string;
    }
    
    // Initialize token state from localStorage
    const initialState = {
      token: localStorage.getItem("token")
        ? JSON.parse(localStorage.getItem("token") ?? "")
        : null,
    };
    
    function reducer(state: AppState, action: Action): AppState {
      console.log("reducer", { state, action });
    
      switch (action.type) {
        case "LOGIN":
          // Persist token value to localStorage
          localStorage.setItem("token", JSON.stringify(action.payload.token));
          return {
            ...state,
            token: action.payload.token,
          };
    
        case "LOGOUT":
          // Remove any stored user data
          localStorage.clear();
          return { ...state, token: null };
    
        default:
          return state;
      }
    }
    
    const ProtectedRoute = () => {
      const state = useAppContext();
    
      return state.token !== null ? <Outlet /> : <Navigate to="/login" replace />;
    };
    
    const AnonymousRoute = () => {
      const state = useAppContext();
    
      return state.token === null ? <Outlet /> : <Navigate to="/" replace />;
    };
    

    Update your routes to wrap the routes you want to protect accordingly.

    const router = createBrowserRouter([
      {
        element: <AnonymousRoute />,
        children: [
          {
            path: "/login",
            element: <Login />,
          },
        ],
      },
      {
        element: <Layout />,
        children: [
          {
            index: true,
            element: <Navigate to="/home" replace />,
          },
          {
            element: <ProtectedRoute />,
            children: [
              {
                path: "/home",
                element: <Home />,
              },
            ],
          },
        ],
      },
    ]);
    

    Update Login and Home buttons to dispatch the actions and effect the navigation changes:

    function Login() {
      const dispatch = useAppDispatchContext();
      const navigate = useNavigate();
    
      return (
        <>
          <h1>Login</h1>
          <button
            type="button"
            onClick={() => {
              dispatch({
                type: "LOGIN",
                payload: {
                  username: "bill",
                  token: "t0k3n",
                },
              });
              navigate("/", { replace: true });
            }}
          >
            Log in
          </button>
        </>
      );
    }
    
    function Home() {
      const dispatch = useAppDispatchContext();
      const navigate = useNavigate();
    
      return (
        <div>
          Home Page
          <button
            type="button"
            onClick={() => {
              dispatch({ type: "LOGOUT" });
              navigate("/login", { replace: true });
            }}
          >
            Log out
          </button>
        </div>
      );
    }
    

    The Layout component is now only responsible for UI layout and route outlet:

    function Layout() {
      return (
        <div>
          <h1>Layout</h1>
          <Outlet />
        </div>
      );
    }
    

    Additional

    Completely unrelated to the routing issue, for improved type safety I recommend you more-strictly type your action objects. Instead of an action object that is any of the type types and a required payload that is typed as any, but more explicit.

    Update from:

    interface Action {
      type:
        | "LOGIN"
        | "LOGOUT"
        | ... ;
      payload?: any;
    }
    

    to something like:

    type Action =
      | {
          type: "LOGIN";
          payload: {
            token: string | null;
            username?: string;
          };
        }
      | {
          type: "LOGOUT";
        }
      | ... ;
    

    Which will allow for your dispatches and reducer cases to better understand and enforce payload values based on the action type.