React Router — Infinite Redirect Loop on Auth Guard
Synchronous localStorage read after login triggered infinite redirects; root cause: async context race.
- React Router enables client-side navigation by intercepting URL changes via the History API, no page reloads.
- Key components: BrowserRouter, Routes, Route, and Outlet for nested layouts.
- Dynamic segments like :id are captured via useParams() — always returns strings, parse explicitly.
- Protected routes use a wrapper component with
to redirect before rendering child routes. - Production pitfall: Missing param in useEffect dependency array causes stale data after navigation.
Imagine a hotel with dozens of rooms. The front desk is the main entrance — but depending on which room number you ask for, the receptionist sends you to a completely different place. React Router is that receptionist. Your app has one entrance (index.html), and React Router decides which 'room' (component) to show based on the URL the user visits. No page reload, no server trip — just instant redirection handled entirely in the browser.
[Already present]
How React Router Actually Works Under the Hood
Most tutorials skip straight to <Route> syntax without explaining the machinery. Let's fix that.
React Router is built on the History API — a browser-native interface that lets JavaScript manipulate the URL bar without triggering a page load. When you call navigate('/dashboard'), React Router pushes a new entry onto the browser's history stack, reads the new URL, matches it against your route definitions, and re-renders the matching components. The server never sees this navigation.
This is why wrapping your app in <BrowserRouter> is non-negotiable. It creates a context that holds the current location and exposes it to every nested component. Without it, hooks like useNavigate and useParams have no context to read from and will throw.
React Router v6 (the current version) made a significant architectural shift: <Switch> was replaced with <Routes>, and route matching became exclusive by default — meaning the first match wins and only that component renders. You no longer need the exact prop. Routes also became composable, so nested routes live inside parent components rather than all being declared in one giant config file.
<Switch> rendered the first matching <Route>. In v6, <Routes> does the same thing by default — every route match is exclusive. The upgrade also means you no longer litter your route config with exact props, which was one of the most common sources of routing bugs in v5 apps.Dynamic Routes, URL Params and the useParams Hook
Static routes handle predictable paths like /about or /contact. But real apps deal with data-driven URLs — product pages, user profiles, order receipts — where the path contains an ID that determines what gets fetched. That's where dynamic segments come in.
When you define path="products/:productId", React Router captures the :productId segment and makes it available via the useParams() hook inside the rendered component. The returned value is always a string, so if your API expects a number, you must parse it explicitly — a silent bug that bites constantly.
You can have multiple dynamic segments in one path: path="orders/:orderId/items/:itemId" is perfectly valid. Each segment becomes a key in the object useParams() returns.
One pattern worth knowing: when a URL param changes (e.g., navigating from /products/1 to /products/2), React doesn't unmount and remount the component — it just re-renders it with new params. This means any useEffect that fetches data based on a param must list that param as a dependency, otherwise you'll display stale data from the previous route.
GET /products/1 with a numeric ID, don't pass productId directly from useParams() — it's the string "1", not the number 1. Use parseInt(productId, 10) or Number(productId) before passing it to any function that needs a real number. TypeScript's React Router types will catch this at compile time, which is a great reason to add TS to your project.Nested Layouts with Outlet — Shared UI Without Repetition
Almost every real app has persistent UI — a navigation bar, a sidebar, a footer — that should stay visible while the main content area changes. The naive approach is to import and render these components in every single page component. That works until you need to change the nav and have to update 20 files.
React Router's <Outlet> component solves this cleanly. A parent route renders a layout component that contains <Outlet /> as a placeholder. When a child route matches, React Router injects that child's component into the <Outlet /> position. The parent layout re-renders zero times — only the outlet's content swaps.
This pattern scales beautifully. You can nest layouts multiple levels deep — an app layout containing an authenticated layout containing a settings layout — each adding its own framing UI without knowing anything about its children.
One underused feature: you can pass context data down through <Outlet context={value} /> and read it in child routes via useOutletContext(). This is useful for passing things like the current authenticated user without threading props manually.
<Link> for any navigation where the visual state doesn't matter (e.g., a 'Back' button). Use <NavLink> for navigation menus where you want the current item styled differently — it automatically receives an isActive boolean in its className and style callbacks, saving you from writing a custom active-detection hook.Protected Routes — Redirect Unauthenticated Users the Right Way
Every app with user accounts has pages that should only be accessible when logged in. A naive approach might be checking auth state inside each page component and calling navigate('/login') if the user isn't authenticated. That works, but it means scattering auth logic across every protected page — a maintenance nightmare.
The idiomatic React Router pattern is a ProtectedRoute wrapper component. It reads auth state in one place, and if the user isn't authenticated, it immediately renders <Navigate /> to redirect them before any protected UI ever renders. If they are authenticated, it renders <Outlet /> to let the matched child route through.
The key detail is storing the originally requested URL in the redirect. When a logged-out user hits /dashboard, you want to send them to /login?redirect=/dashboard so after they authenticate, you can send them exactly where they wanted to go — not just to a generic home page. This is the difference between a frustrating UX and a seamless one.
This pattern also composes naturally with role-based access: you can create a <RequiresAdminRole /> wrapper that checks the user's role, not just whether they're logged in.
const navigate = useNavigate() and then immediately calling navigate('/login') at the top of a component body (outside useEffect). This fires during the render cycle and can cause React warnings about state updates during render. Always use the declarative <Navigate /> component for redirect-on-render scenarios, and keep useNavigate() for event handlers and effects only.Programmatic Navigation and Route Transitions
Sometimes you need to navigate in response to an event — form submission, button click, timeout — not just a link click. React Router's useNavigate() hook gives you a function you can call anywhere in your component (inside event handlers, effects, callbacks) to navigate programmatically.
useNavigate() returns a function: navigate(to, options). The to argument is a path or a number (-1 for back, 1 for forward). Options include replace (boolean) and state (any serializable data to pass to the target route). Pass state when you need to communicate transient data like 'order just placed' without polluting the URL.
One difference from <Link>: programmatic navigation doesn't automatically handle 'open in new tab'. If you need that, use a <Link> with target="_blank" or construct an anchor tag manually with .window.open()
Route transitions can be enhanced with animations using libraries like framer-motion or react-transition-group. The key is wrapping the element that changes (often the <Outlet />) in an animation component that listens to route changes via useLocation().
Also worth knowing: React Router v6.4+ introduced useBlocker() and usePrompt() to prevent navigation when there are unsaved changes — critical for forms.
- If you find yourself calling
at the top of a component (outside useEffect), refactor to use <Navigate /> or move it into an event handler.navigate() - Use
replace: truefor redirects that should not create new history entries (e.g., after login, to prevent the user from going back to the login page). - Pass state via options.state — it's ephemeral, lost on hard refresh. Never use it for critical data.
- For 'open in new tab', use a normal <Link> or
window.open()— programmatic navigation can't open new tabs.
navigate() inside an effect without proper dependencies, leading to infinite redirects or stale navigation targets.Infinite Redirect Loop on Auth Guard
replace: true which replaced the current history entry, making the loop invisible in the browser's back button.- Never trust synchronous storage reads for auth state — async context prevents race conditions.
- Always show a loading state while auth resolves; redirect only after confirmation.
- Use a dedicated auth provider that emits authenticated/unauthenticated as a state, not a side effect.
- Avoid using replace in redirects during auth flows — it hides the loop from the user.
try_files $uri /index.html;. For Netlify: add /* /index.html 200 to _redirects.<Outlet />. If using v5, check for missing exact prop. In v6, verify child routes are defined inside the parent <Route> block.className callback with isActive parameter. Check that the paths match exactly — trailing slashes matter. For partial matching, use end prop on NavLink.Key takeaways
Common mistakes to avoid
4 patternsForgetting useParams() values are always strings
userId=undefined or a string where the backend expects a number), causing 400 errors or fetching the wrong data.parseInt(id, 10) or Number(id) before using them in API calls or comparisons.Missing the dependency array entry for route params in useEffect
/products/1 to /products/2 shows product 1's data until a full page refresh, because the effect never re-runs.useParams() that you use inside a useEffect in that effect's dependency array.Calling useNavigate() imperatively during the render cycle instead of using <Navigate />
<Navigate to="/login" replace /> component for any redirect that should happen as part of rendering (like auth guards), and save useNavigate() exclusively for event handlers and async callbacks inside useEffect.Not handling the 404 on page refresh in production deployments
/dashboard), the server returns a 404 because it has no file at that path.index.html for all routes. In Nginx: try_files $uri /index.html;. In Netlify: add /* /index.html 200 to _redirects.Interview Questions on This Topic
What's the difference between and
<Link> is the basic component for navigation. It renders an anchor tag and navigates to the given to path without a page reload. You use it for any link where you don't need to style the current page differently — back buttons, sidebar links that always look the same, etc.
<NavLink> extends <Link> and automatically adds an isActive boolean to its className and style callbacks. This makes it perfect for navigation menus where the current page should be visually highlighted. For example, in a sidebar with links to /dashboard, /orders, /settings, NavLink lets you easily apply an 'active' class to the current section.
In production, use <Link> for generic navigation (breadcrumbs, 'go back', external links) and <NavLink> for primary navigation menus that need active state styling. Overusing NavLink when you don't need active detection adds unnecessary re-renders — each NavLink subscribes to location changes.Frequently Asked Questions
That's React.js. Mark it forged?
4 min read · try the examples if you haven't