React Router Auth Guards: The children Pattern, Redirects, and History Cleanup
Frontend Routing (Part 3): Authentication, Interception, and Redirection
Background
The previous article (Frontend Routing (Part 2): Component-Based Routing in Practice with React Router) used React Router to build the component-based skeleton of routing: <Routes> declares the mapping from paths to components, <Link> handles navigation, useParams extracts dynamic parameters, <Outlet> carries nested rendering, and lazy() performs code splitting. At this stage, switching between pages already works correctly.
But there is a gap between "being able to switch pages" and "being able to manage access." A user-facing web application typically requires:
- Certain pages are only accessible after login (e.g., payment page, user profile)
- Unauthenticated users accessing restricted pages are automatically redirected to the login page
- After a successful login, the user returns to the page they originally intended to visit
- The login page leaves no trace in the browsing history—pressing "back" should not show the login form again
These requirements are no longer about "how to configure routes" but about "how routes make decisions at runtime." Based on the newly added authentication-related code in the react-router-demo, this article dissects the implementation of route guards, login redirection, and state passing patterns.
Route Guards: Conditional Rendering with the children Pattern
The responsibility of a route guard is to make a judgment before entering a target page: does the current user have access? If yes, render normally; if not, intercept and redirect.
The implementation of ProtectRoute.jsx:
import { Navigate } from 'react-router-dom'
const ProtectRoute = ({ children }) => {
const isLogin = localStorage.getItem('isLogin') === 'true'
if (!isLogin) {
return (
<Navigate to="/login" replace state={{ from: location.pathname }} />
)
}
return <>{children}</>
}
export default ProtectRoute
The structure of this component is worth breaking down.
children is a universal pattern for route guards. ProtectRoute does not care what page it wraps—it only makes a judgment. It receives children via props, and the caller decides what content is protected:
<ProtectRoute>
<Pay />
</ProtectRoute>
This approach completely decouples guard logic from page logic. ProtectRoute can wrap any component—payment page, user profile, admin dashboard—without needing to write a separate guard for each protected page.
The judgment logic relies on localStorage. HTTP is a stateless protocol, so login state needs to be maintained on the client side. Three common approaches exist: carrying a token in request headers, cookies, and localStorage. The demo chose localStorage—when login is successful, isLogin = 'true' is written, and ProtectRoute reads this flag to make its judgment. The advantage of choosing localStorage is that both reading and writing are done entirely on the frontend, without depending on server-side sessions, making it suitable for quick validation at the demo level.
When not logged in, it returns a <Navigate> component. <Navigate> is a declarative redirection component provided by React Router—rendering it triggers a route jump. The two attributes replace and state here each serve a purpose:
replace: Replaces the current history entry with the new URL, preventing the user from going "back" to the page before the interception.state: Carrieslocation.pathname—the page the user originally wanted to go to. This value will be used to redirect back after a successful login.
Login and Redirect Back: useNavigate, location.state, and replace
Login/index.jsx is the other half of the authentication flow:
import { useNavigate, useLocation } from 'react-router-dom'
const Login = () => {
const navigate = useNavigate()
const location = useLocation()
const from = location.state?.from || '/'
function handleSubmit(e) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const username = formData.get('username')
const password = formData.get('password')
if (username === 'admin' && password === '123456') {
localStorage.setItem('isLogin', 'true')
navigate(from, { replace: true })
}
}
return (
<form onSubmit={handleSubmit}>
<h1>Login</h1>
<input name="username" placeholder="Please enter username" required />
<input name="password" placeholder="Please enter password" required />
<button type="submit">Login</button>
</form>
)
}
Several design choices here are worth expanding on.
const from = location.state?.from || '/'—optional chaining + default value.
location.state is auxiliary data carried during a route jump and does not appear in the URL. When a user directly accesses /login (rather than being redirected after an interception), location.state is undefined. The optional chaining operator ?. prevents an error from accessing undefined.from, and || '/' provides a default redirect target.
A user intercepted from the payment page will have a from value of /pay and will return to the payment page after login. A user who directly accesses the login page will have a from value of / and will return to the homepage after login. One expression covers both scenarios.
navigate(from, { replace: true })—replaces the history entry after a successful login.
replace: true is used here, echoing the <Navigate replace> in ProtectRoute. Without replace, the redirect after a successful login would add a new entry to the history stack. When the user presses the "back" button, the browser would return to the login page—at which point the user is already logged in, the login page reads localStorage, determines the user is logged in, and automatically redirects away again. This loop won't throw an error, but it makes navigation behavior unpredictable.
replace: true overwrites the login page's history entry with the new page's entry. When the user presses "back" on the page after a successful login, they will skip the login page entirely and return to the page before entering the login page. For the user, login is a "transparent intermediate step"—they were there, but no trace is found in the history.
The correspondence between useNavigate and <Navigate>. <Navigate> is a declarative approach, suitable for conditional rendering within JSX; useNavigate() is a programmatic approach, suitable for triggering jumps within event handlers or asynchronous callbacks. ProtectRoute uses the former (directly returning <Navigate> when determining the user is not logged in), while Login uses the latter (calling navigate() only after the form is successfully submitted). The same set of route jumping capabilities, two different entry points for usage.
Chaining: A Complete Authentication Flow
Put the above components back into the route configuration to see the path of a complete request.
The relevant routes in App.jsx:
<Route path="/login" element={<Login />} />
<Route path="/pay" element={
<ProtectRoute>
<Pay />
</ProtectRoute>
} />
When a user clicks the "Pay" link in the navigation bar:
- The route matches
/pay, rendering<ProtectRoute><Pay /></ProtectRoute>. ProtectRoutereadslocalStorageand findsisLogin !== 'true'.- It returns
<Navigate to="/login" replace state={{ from: '/pay' }} />, triggering a redirect. - The URL becomes
/login, rendering<Login />, withlocation.state.frombeing/pay. - The user enters their username and password and clicks login.
handleSubmitwrites tolocalStorageand callsnavigate('/pay', { replace: true }).- The URL becomes
/pay,ProtectRoutejudges again—this timeisLogin === 'true', rendering<Pay />. - The user presses "back," and the browser returns to the page before
/login—the login page has been removed from the history stack.
Throughout the entire process, the route guard, login form, state passing, and history management each perform their own roles, combining to form a complete access control chain.
Review of Three Parts: A Path from Principles to Practice
The three articles in this series cover three layers of frontend routing:
- Principle Layer (Part 1): How the browser manages URLs—the
hashchangeevent of Hash, and thepushStateandpopstateof the History API. Writing a HashRouter by hand to understand that the essence of routing is "key-value mapping + event listening." - Configuration Layer (Part 2): How React Router organizes a route table into a component tree—declarative configuration with
<Routes>, navigation with<Link>, parameter extraction withuseParams, nested rendering with<Outlet>, and on-demand loading withlazy(). The core problem component-based routing solves is: when the number of routes scales from three pages to thirty, how to manage complexity with a consistent API. - Control Layer (Part 3, this article): How routes perform access control at runtime—the
childrenpattern for guard components, declarative redirection with<Navigate>, programmatic jumping withuseNavigate, cross-page state passing withlocation.state, and history management withreplace. These patterns elevate routing from "being able to switch" to "being able to decide."
The relationship between the three layers is progressive, not substitutive: the principle layer explains "why it works," the configuration layer provides "how to organize," and the control layer supplements "how to decide." In actual projects, all three layers exist simultaneously—the browser's URL API at the bottom, the framework's route configuration in the middle, and the business logic's guards and redirect logic on top.
Summary
This set of authentication routing patterns essentially inserts a judgment node into the route matching-rendering process. ProtectRoute uses children to receive protected content, localStorage for state judgment, <Navigate> to trigger interception jumps, and location.state to record the origin path. Login uses useNavigate for programmatic redirect-back and replace: true to clean up the history record.
These patterns are not limited to authentication scenarios. Any logic that requires a pre-check before entering a page—role permissions, feature flags, A/B experiment traffic splitting—can reuse the same "guard component + children + conditional redirection" structure. Understanding this layer means frontend routing is no longer just a "tool for switching pages," but a runtime framework capable of carrying business decisions.