跪拜 Guibai
← Back to the summary

How Route Guards, Login Auth, and Hidden State Actually Work in React Router v6

React Router Advanced: Route Guards, Login Authentication, and State Passing

Foreword

In the previous article, we built the basic skeleton of React Router v6—route matching, lazy loading, dynamic parameters, nested routes, and a 404 fallback. But that was still an "open door" application: anyone could access all pages by typing a random URL.

In a real project, some pages require login to view (payment page, personal center, admin dashboard). This brings us to today's main topics: Route Guards, Login Authentication, and how React Router passes state between different pages during this process.


1. HashRouter vs BrowserRouter: Thoroughly Understanding the Two Routing Modes

// App.jsx —— Today we switched from HashRouter to BrowserRouter
import {
  BrowserRouter as Router,  // ← Switched!
  Routes,
  Route,
  Navigate,
} from 'react-router-dom';

How HashRouter Works

The content after the # in a URL is called the fragment identifier. Its core characteristic is: changing the hash does not trigger the browser to send a request to the server.

http://localhost:5173/#/pay
                      └────────── hash part, the browser ignores it

When you switch from #/home to #/pay:

  1. The browser does not initiate an HTTP request.
  2. But it triggers the hashchange event.
  3. React Router listens for this event and switches components based on the hash value.

Pros: Zero configuration, the server doesn't need any cooperation. It runs on any static server. Cons: The URL has a #, which isn't "clean" and doesn't look like a traditional webpage.

How BrowserRouter Works

BrowserRouter relies on the History API introduced by HTML5:

// Underlying principle
window.history.pushState(null, '', '/pay');  // URL changes, page doesn't refresh

It can directly manipulate the full URL path without needing #:

http://localhost:5173/pay   ← Clean URL, no #

But there is a key prerequisite: When a user directly accesses /pay or refreshes the page, the browser sends a GET request to the server. The server must be configured with a rule—all paths return index.html, allowing the frontend React Router to take over route matching.

User refreshes /pay → Server request GET /pay
  ├─ No fallback configured → Server returns 404
  └─ Fallback configured → Server returns index.html → React Router takes over → Matches /pay and renders the component

Vite's development mode has this configured by default. For production, you need to add this in Nginx:

location / {
    try_files $uri /index.html;
}
HashRouter BrowserRouter
URL Appearance /#/pay /pay
Principle hashchange event History API (pushState)
Server Config Not needed Needs fallback to index.html
Use Case Demos, GitHub Pages, static deployment Production environment, SEO-friendly

Use HashRouter for convenience during the demo phase, and switch to BrowserRouter for a more professional look when going live.


2. Restful Design Philosophy: The URL is the Resource

Before diving deeper into routing, let's clarify a design concept: Everything is a resource.

/products          → Product list resource
/products/123      → Product resource with id=123
/products/new      → Entry resource for creating a new product
/user/123          → User resource with id=123
/pay               → Payment page resource
/login             → Login page resource

Each URL path corresponds to a logical resource. Route switching = accessing different resources, rather than "jumping to a new page."

This also explains why frontend routing needs to take over the URL—the URL is the entry point for user interaction with the application, and it should accurately express "what you are currently viewing."


3. Routing Objects: Three Core Concepts of Frontend Routing

The browser exposes three core objects, and React Router wraps them in a React-style manner:

Native Concept React Router Hook Function
window.navigator (No direct Hook, internally wrapped by Link/Navigate) Navigation capability
window.location useLocation() Current URL
window.history useNavigate() Manipulating the history stack
┌─────────────────────────────┐
│  Browser                      │
│                             │
│  navigator  → "Where you can go"     │
│  location   → "Where you are now"    │
│  history    → "Where you've been before"  │
│                             │
│  React Router wraps them into   │
│  components and Hooks, allowing you to  │
│  naturally operate routing in JSX              │
└─────────────────────────────┘

Understanding these three concepts helps you understand why Link doesn't refresh the page—it calls history.pushState under the hood, changing the URL without sending a request; and why useLocation().state can pass data across pages—because pushState itself supports carrying a state object.


4. Route Guards: Using {children} as an "Access Gate"

// ProtectRoute.jsx
import { Navigate } from 'react-router-dom';

const ProtectRoute = ({ children }) => {
  // Read login status from localStorage
  const isLogin = localStorage.getItem('isLogin') === 'true';

  if (!isLogin) {
    // Not logged in → Redirect to /login, carrying "where they came from"
    return <Navigate to="/login" replace state={{ from: location.pathname }} />;
  }

  // Logged in → Allow access, render child components
  return <>{children}</>;
};

Design Pattern: children as a "Slot"

ProtectRoute doesn't care what component it wraps; it does only one thing: check permissions → decide to allow or intercept. What exactly gets rendered is completely passed in by the consumer via children:

// App.jsx
<Route path="/pay" element={
  <ProtectRoute>
    <Pay />          {/* ← children, ProtectRoute doesn't care what it is */}
  </ProtectRoute>
} />

This is the essence of React componentization—separation of concerns. ProtectRoute only cares about "whether there is permission," and Pay only cares about "what the payment page looks like." The two are loosely coupled together through children.

children is essentially all the content written inside the JSX tags when the component is called:

<ProtectRoute>
  <Pay />           ← This is children
</ProtectRoute>

// Equivalent to
ProtectRoute({ children: <Pay /> })

5. Navigate's state Object: Passing "Hidden Data" Across Pages

<Navigate to="/login" replace state={{ from: location.pathname }} />
//                               └────────────────────────────────┘
//                          state object: invisible in the URL, accessible by the target page

What Problem Does It Solve

A user types #/pay in the address bar, ProtectRoute finds they are not logged in, and redirects to /login. After a successful login, where should they go back to? Someone must tell the Login page "the user came from /pay." This information cannot be placed in the URL (it would expose internal paths and disrupt the Login page's URL structure), but it needs to be passed between pages—state is exactly for this.

How Data Flows

User accesses /pay (not logged in)
  │
  ▼
ProtectRoute:
  <Navigate to="/login" state={{ from: '/pay' }} />
  │
  │  state is written to history.state
  │
  ▼
Address bar becomes /login, but state is hidden in the history stack
  │
  ▼
Login component:
  const location = useLocation();
  const from = location.state?.from || '/';   // → '/pay'
  │
  ▼
Login successful → navigate(from) → Sends back to /pay

Why state Doesn't Get Lost on Refresh

Because React Router calls history.pushState(state, '', url) under the hood, the state object is serialized and stored by the browser in the history stack. After a page refresh, it is restored from history.state, so the Login page can still retrieve from even after a refresh.


6. Login Page: Form, State Storage, Preventing Back Navigation

// Login/index.jsx
import { useNavigate, useLocation } from 'react-router-dom';

const Login = () => {
  const navigate = useNavigate();
  const location = useLocation();

  // Read the "source path" passed by ProtectRoute
  const from = location.state?.from || '/';

  function handleSubmit(e) {
    e.preventDefault();  // Prevent the form's default submit behavior (which would refresh the page)

    // FormData API: Extract user input from the <form> element
    const formData = new FormData(e.currentTarget);
    const username = formData.get('username');
    const password = formData.get('password');

    // Hardcoded authentication (for demo purposes; production needs backend verification)
    if (username === 'admin' && password === '123456') {
      localStorage.setItem('isLogin', 'true');
      navigate(from, { replace: true });  // ← Key: replace mode
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="username" placeholder="Please enter username" required />
      <input name="password" placeholder="Please enter password" required />
      <button type="submit">Login</button>
    </form>
  );
};

FormData: Getting Form Data Without Controlled Components

In React, forms are typically handled with "controlled components"—each input is bound to a state:

// Controlled component approach (common but tedious)
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
<input value={username} onChange={e => setUsername(e.target.value)} />

FormData is a native browser API that directly reads input values corresponding to the name attribute from the <form> DOM element. It doesn't need state or onChange. For scenarios like login forms where "values are only needed on submit," it's much simpler.

Note: inputs must have the name attribute; FormData uses it to index values.

navigate(from, { replace: true }): Why Use replace?

History stack (push mode):
┌─────────┐
│  /      │  ← Initial page
├─────────┤
│  /pay   │  ← User tried to access
├─────────┤
│  /login │  ← Redirected to login page
└─────────┘
User clicks "back" → Returns to /pay → Intercepted by ProtectRoute again → Redirected to /login again
                                                      ↑
                                      Infinite loop! User is "trapped" on the login page
History stack (replace mode):
┌─────────┐
│  /      │  ← Initial page
├─────────┤
│  /pay   │  ← /login replaced this record!
└─────────┘
User clicks "back" → Returns to / → Everything is normal

The role of replace: true: Replace the current history record with the new path, instead of adding a new record. After a successful login, the /login history record should not exist—the user doesn't need to "go back to the login page," as going back would only get them intercepted again.


7. localStorage: Why Use It to Store Login State

localStorage.setItem('isLogin', 'true');
localStorage.getItem('isLogin') === 'true';

HTTP is Stateless

At the protocol level, each HTTP request is independent; the server won't "remember" what you did in the last request. For a webpage to "remember" that a user is logged in, it must find its own way to save the state.

Three Common Approaches

Approach Storage Location Lifecycle Use Case
localStorage Browser disk Permanent (unless manually cleared) Login state, user preferences
sessionStorage Browser memory Cleared when tab is closed Temporary data, form drafts
Cookie Browser + automatically sent to server with each request Expiry time can be set Session Token

The demo uses the simplest localStorage—it isolates storage by domain (all pages under the same domain share it, different domains cannot see each other's). React Router's ProtectRoute and Login are under the same domain, so the isLogin written by Login can be read by ProtectRoute.

localhost:5173's localStorage:
  isLogin: "true"     ← Written by Login page
                         Read by ProtectRoute ← Same domain sandbox, shared

8. Complete Authentication Flow Summary

Stringing together all the new parts added today:

User clicks "Pay" → <Link to="/pay">
        │
        ▼
Routes matches path="/pay"
        │
        ▼
<ProtectRoute> is rendered
  localStorage.getItem('isLogin') === 'true' ?
        │
  ┌─────┴─────┐
  │ false     │ true
  ▼           ▼
Redirect       Render <Pay />
/login
  │
state={{ from: '/pay' }}
  │
  ▼
Login component renders
  useLocation().state?.from → '/pay'
  │
  ▼
User enters admin / 123456 → handleSubmit
  localStorage.setItem('isLogin', 'true')
  navigate('/pay', { replace: true })
  │
  ▼
ProtectRoute checks again
  isLogin === true → Allow access
  │
  ▼
<Pay /> renders normally, URL is /pay

Key Points Quick Reference

Concept One-sentence explanation
BrowserRouter Uses the History API to manipulate full URL paths, requires server fallback support
HashRouter Uses the URL # part for routing, zero configuration, URL has a #
children pattern Content inside component tags passed as a prop, implementing a loosely coupled "slot"
Route Guard Wraps sensitive pages, checks permissions, and decides to allow or redirect
Navigate's state Passes "hidden data" (like source path) across pages, doesn't enter the URL, doesn't get lost on refresh
useLocation() Reads information about the browser's current URL, including state
FormData Native browser API, extracts input values with name attributes from <form>
localStorage Persistent local storage in the browser, isolated by domain
navigate(path, {replace}) Replaces the current history record, preventing users from going back to pages they shouldn't
Restful URL paths correspond to resources, /products/123 is the product with id=123

The essential capabilities of React Router can be summarized in three points: Matching (URL → Component), Navigation (Component → URL), and Parameter Passing (route params + state). Authentication routing is a combined application of these basic capabilities—checking state (localStorage), conditional navigation (Navigate), cross-page parameter passing (state), and preventing back navigation (replace). Once you thoroughly understand these atomic capabilities, you'll find that any complex frontend routing scenario, when broken down, cannot escape these three dimensions.