Search code examples
reactjstypescriptreact-router-dom

Cannot use useNavigate in custom hook with createBrowserRouter


I have this custom hook for login and logout

export function useUser() {
    const { token, setToken, user, setUser } = useContext(AuthContext)
    const navigate = useNavigate()

    const {
        mutate: login,
        isLoading: isLoginLoading,
        isError: hasLoginError,
        
    } = useMutation({
        mutationFn: signIn,
        onSuccess: async (tokenResponse) => {
            sessionStorage.setItem('access_token', tokenResponse.access_token)
            sessionStorage.setItem('refresh_token', tokenResponse.refresh_token)
            setToken(tokenResponse.access_token)
            const user = await getUserProfile()
            setUser(user)
            sessionStorage.setItem('userData', JSON.stringify(user))
            navigate('/home')
        },
        onError: () => {
            sessionStorage.removeItem('access_token')
        }
    })

    const { mutate: logout } = useMutation({
        mutationFn: signOut,
        onMutate: () => {
            sessionStorage.clear()
            setToken(null)
            setUser(null)
        },
        onSuccess: async () => {
            navigate('/login')
        }
    })

    return {
        login,
        logout,     
        isLoginLoading,
        hasLoginError,
        userPermissions: token ? (jwt_decode(token) as TokenDecoded).authorities : [],
        user
    }
}

And I want to take advantange of the new data router with react-router-dom createBrowserRouter and try to create a breadcrumb. Before this I use <BrowserRouter> and it works fine, but with this new approach, and because of the use of protected routes, I get the error.

useNavigate() may be used only in the context of a component.

I perfectly understand the error, but I don't know how to make it work. This is related with another issue that I post this issue and with a similar issue similar issue. Here is my code :

CommonLayout.tsx and AuthLayout.tsx

export const CommonLayout = ({ isAllowed }: Props) => {
    return (
        <div className='flex flex-col h-screen'>
            {isAllowed ? <Navbar /> : <GuestNavbar />}
            <main className='flex-1 min-h-max p-8 bg-slate-100 dark:bg-neutral-800'>
                <Outlet />
            </main>
            <Footer />
        </div>
    )
}

export const AuthLayout = ({ isAllowed, redirectPath, children }: Props) => {
    const location = useLocation()

    return isAllowed ? (
        children ?? <Outlet />
    ) : (
        <Navigate to={redirectPath} replace state={{ from: location }} />
    )
}

And the new approach that I want:

export function App() {
    const { user, userPermissions } = useUser()
    const router = createBrowserRouter(
        createRoutesFromElements(
            <>
                <Route element={<CommonLayout isAllowed={!!user} />}>
                    <Route path='login' element={<Login />} />
                    <Route element={<AuthLayout isAllowed={userPermissions.includes('PERMISSION_1')} redirectPath='/login' />}>
                        <Route
                            path='home'
                            element={
                                <>
                                    <Breadcrumbs />
                                    <h1>Home</h1>
                                </>
                            }
                            handle={{ crumb: () => 'Home' }}
                        />
                        <Route
                            path='tracking'
                            element={
                                <Suspense fallback={<>...</>}>
                                    <Breadcrumbs />

                                    <Tracking />
                                </Suspense>
                            }
                            handle={{ crumb: () => 'Tracking' }}
                        />
                        <Route
                            path='cleaning'
                            element={
                                <Suspense fallback={<>...</>}>
                                    <Breadcrumbs />

                                    <Cleaning />
                                </Suspense>
                            }
                        />
                        <Route
                            path='admin'
                            element={
                                <Suspense fallback={<>...</>}>
                                    <Breadcrumbs />
                                    <Admin />
                                </Suspense>
                            }
                            handle={{ crumb: () => 'Admin' }}
                        />
                    </Route>
                </Route>
                <Route path='*' element={<Navigate to='/home' replace />} />
            </>
        )
    )
    return <RouterProvider router={router} />
}

As you can see, I need the user because some routes need it, but importing the hook throws the error. How can I avoid this?

The first approach is removing the useNavigate from the hook, useUser.

A second one is modify the layouts, so that the layouts import the hooks and receive as params something like a permission (in case of permissions) and a condition (in case I need it)

Thanks in advance


Solution

  • The useUser hook can only be called within a routing context provided by a router, so it will need to be moved.

    A second one is modify the layouts, so that the layouts import the hooks and receive as params something like a permission (in case of permissions) and a condition (in case I need it).

    This is the method I'd recommend. The useUser hook is already reading what I assume is a single global AuthContext value, so from what I can see it's completely safe to push the useUser hook calls down the ReactTree to the components that need it.

    Update the layout components to access the useUser hook value and pass in the static data as props.

    export const CommonLayout = () => {
      const { user } = useUser();
    
      return (
        <div className='flex flex-col h-screen'>
          {!!user ? <Navbar /> : <GuestNavbar />}
          <main className='flex-1 min-h-max p-8 bg-slate-100 dark:bg-neutral-800'>
            <Outlet />
          </main>
          <Footer />
        </div>
      )
    }
    
    export const AuthLayout = ({ children, redirectPath, roles = [] }: Props) => {
      const location = useLocation();
      const { userPermissions } = useUser();
    
      const isAllowed = userPermissions.some(permission => roles.includes(permission));
    
      return isAllowed ? (
        children ?? <Outlet />
      ) : (
        <Navigate to={redirectPath} replace state={{ from: location }} />
      );
    }
    

    Update the layout route components accordingly. CommonLayout doesn't consume any props now, and AuthLayout is passed an array of roles/permissions. The useUser hook call is removed.

    export function App() {
      const router = createBrowserRouter(
        createRoutesFromElements(
          <>
            <Route element={<CommonLayout />}>
              <Route path='login' element={<Login />} />
              <Route
                element={(
                  <AuthLayout roles={['PERMISSION_1']} redirectPath='/login' />
                )}
              >
                <Route
                  path='home'
                  element={
                    <>
                      <Breadcrumbs />
                      <h1>Home</h1>
                    </>
                  }
                  handle={{ crumb: () => 'Home' }}
                />
                <Route
                  path='tracking'
                  element={
                    <Suspense fallback={<>...</>}>
                      <Breadcrumbs />
                      <Tracking />
                    </Suspense>
                  }
                  handle={{ crumb: () => 'Tracking' }}
                />
                <Route
                  path='cleaning'
                  element={
                    <Suspense fallback={<>...</>}>
                      <Breadcrumbs />
                      <Cleaning />
                    </Suspense>
                  }
                />
                <Route
                  path='admin'
                  element={
                    <Suspense fallback={<>...</>}>
                      <Breadcrumbs />
                      <Admin />
                    </Suspense>
                  }
                  handle={{ crumb: () => 'Admin' }}
                />
              </Route>
            </Route>
            <Route path='*' element={<Navigate to='/home' replace />} />
          </>
        )
      );
    
      return <RouterProvider router={router} />;
    }