跪拜 Guibai
← Back to the summary

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:

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:

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:

  1. The route matches /pay, rendering <ProtectRoute><Pay /></ProtectRoute>.
  2. ProtectRoute reads localStorage and finds isLogin !== 'true'.
  3. It returns <Navigate to="/login" replace state={{ from: '/pay' }} />, triggering a redirect.
  4. The URL becomes /login, rendering <Login />, with location.state.from being /pay.
  5. The user enters their username and password and clicks login.
  6. handleSubmit writes to localStorage and calls navigate('/pay', { replace: true }).
  7. The URL becomes /pay, ProtectRoute judges again—this time isLogin === 'true', rendering <Pay />.
  8. 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:

  1. Principle Layer (Part 1): How the browser manages URLs—the hashchange event of Hash, and the pushState and popstate of the History API. Writing a HashRouter by hand to understand that the essence of routing is "key-value mapping + event listening."
  2. Configuration Layer (Part 2): How React Router organizes a route table into a component tree—declarative configuration with <Routes>, navigation with <Link>, parameter extraction with useParams, nested rendering with <Outlet>, and on-demand loading with lazy(). 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.
  3. Control Layer (Part 3, this article): How routes perform access control at runtime—the children pattern for guard components, declarative redirection with <Navigate>, programmatic jumping with useNavigate, cross-page state passing with location.state, and history management with replace. 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.