Errors and fixes
React Router: navigating from code without breaking the router
Where you are decides the call: `useNavigate` in a component, `redirect` in a loader, the router instance outside React for anything else.
Key points
- Inside a component use `useNavigate` and call it from an event or an effect, never during the render.
- In a loader or an action use `redirect`, which is data and not a side effect.
- Outside React you need the instance from `createBrowserRouter`: `router.navigate()` also works in an interceptor.
Short answer: inside a component use useNavigate() and call it from an event or an effect; in a loader use redirect(); outside React use the router instance from createBrowserRouter. Never call navigate() during the render.
The mechanism: navigation is state, not a call
The router holds a location and a set of subscribers. Calling navigate does not change the page: it proposes a new location, the router compares it with the current one, updates history and notifies the components that depend on that context. The render arrives afterwards. That is why a call during render is an error: you are asking the router to notify a component while that component is still being computed. React reports it as updating another component during render, and Strict Mode doubles the request with its second render.
A diagnostic step: watch the location, not the screen
When it seems not to navigate, the first question is whether the location changed. The snippet below subscribes to the router and prints every transition: if the path changes and the screen does not, the problem is in how the route renders; if the path does not change, the call never fired or was cancelled. Remember to unsubscribe, or in development you accumulate one listener per hot reload.
// la navigazione è uno stato: guarda cosa cambia davvero
const unsubscribe = router.subscribe((state) => {
console.log(state.navigation.state, state.location.pathname, state.location.key);
});
// chiama unsubscribe() quando smonti il modulo
Inside a component
import { useNavigate } from "react-router";
export function SaveButton() {
const navigate = useNavigate();
async function onSave() {
await save();
navigate("/dashboard", { replace: true });
}
return Salva;
}
replace: true replaces the history entry, so after a save the Back button does not return to the filled form. That is a product decision, not a technical detail.
In loaders and actions
import { redirect } from "react-router";
export async function loader({ request }: { request: Request }) {
const user = await getUser(request);
if (!user) throw redirect("/login");
return { user };
}
redirect is a return value, not an effect: it stops the flow and the router handles the transition. It is the right shape when the decision depends on data loaded before the render.
Outside React
// router.ts
export const router = createBrowserRouter(routes);
// http.ts
import { router } from "./router";
http.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) router.navigate("/login");
return Promise.reject(error);
});
Hooks do not exist outside a component, so useNavigate in a network module throws. The router instance is the only stable entry point.
When the fix does not work
If navigation happens but the page does not change, the component stays mounted: two routes render the same element with the same key. If you get a loop instead, you call navigate in an effect without stable dependencies. The new location changes the dependencies and the effect runs again. Navigation belongs in the event handler, not in the effect.
When the common fix makes things worse
The first fix you meet is replace: true, useful after a save and harmful as a habit. Replacing the history entry means Back skips the previous screen: someone who wanted the form again lands two steps away, and the middle step no longer exists for anyone. Measurement suffers too, because a page that never enters the history produces no separate pageview and funnels look shorter than they are. replace is a product decision to take case by case, not a way to keep the filled form out of the history.
A limitation with a name: no instance in declarative routing
The limitation has a technical name: declarative routing. With <BrowserRouter> the history lives inside the component, so an HTTP interceptor or a generated client has nothing to call, and the instance mentioned above does not exist. The answer is not a History API trick: bring the event into the component, with shared state the interceptor updates and an effect that reads it. Anyone moving to React Router 7 in framework mode finds the instance ready and never meets the problem.
How to prevent it
- One rule: navigation follows an action, it is not a render calculation.
- Prefer loaders for decisions that depend on data.
- In tests, check the history: after a save, Back must not return to the form.
History is part of behaviour, so test it as behaviour: after a save, Back must not show the form; after a 401, the protected route must not stay reachable through Back. Two assertions cover the two bugs this page describes.
Where to go next
To build screens: dashboard with a shell, components and blocks with a preview. The guides: the App Router structure and copy-paste or headless; the comparison: alternatives to MUI, measured.
Frequently asked questions
Why does navigate() during the render cause a loop?
Navigation changes the location, which re-renders the component, which navigates again. Call it in an event handler, or in useEffect with stable dependencies.
How do I navigate after an API call?
In the component, await the promise and then call navigate. If the call lives in a shared layer, return the result and navigate from the component.
Are redirect and navigate equivalent?
No. redirect is thrown in a loader or an action and stops the flow, while navigate is an imperative command from the client.
Does it work with React Router 7 in framework mode?
Yes, with the same split: useNavigate in components, redirect in loaders. In framework mode loaders are the preferred route.
How do I navigate from an axios interceptor?
Export the router instance from createBrowserRouter and call router.navigate('/login'). Do not use hooks outside a component.