跪拜 Guibai
← Back to the summary

How React Router Guards, Redirects, and Remembers Where Users Were Going

Continued from the previous article, the corresponding source code is all updated in the previous article

New additions

e4618759188c1eaafb735954e7638a2a.png

image.png

  • Authentication Routing

    • HTTP is stateless
    • Stateful?
      • Request header token Authorization
      • Cookie
      • localStorage stores login state user admin password 123456
    • Child components inside a component props.children gets all the child nodes declared inside the component. Modal popup component mask overlay Window header, footer, main part passed in via children. Customizability

    {children customization}

    Route Object

    • SPAs need front-end routing
    • URL changes, corresponding to different resources RESTful design concept hash #/pay browserRouter History
    • navigator
    • location
    • history object
    • Link component to replace

    Two Routing Options

    • hashRouter URL partially changes the hash part The URL is a bit for front-end routing, the URL is a bit ugly, different from back-end routing /pay #/pay
    • browserRouter is not a hash solution to implement SPA

4.9 Three Practical Scenarios for Route Redirection (Pyramid Peak - Core Conclusion)

The essence of route redirection is the "permanent/temporary migration of a URL"—when a URL no longer points to valid content, the <Navigate> component or useNavigate Hook seamlessly guides the user to the correct target page. It is not a simple "jump," but a complete design pattern encompassing history management, state passing, and user experience considerations.

In real business, route redirection mainly has three scenarios: Campaign offline migration (redirecting to a results page after a marketing campaign ends), Path restructuring (permanently migrating old URLs to new ones), and Conditional redirection (redirecting unauthenticated users to a login page when they access a protected page). Let's break them down one by one.

4.9.1 Scenario 1: Marketing Campaign Offline — /game/result

This is the most classic redirection scenario. Suppose the operations team ran a user acquisition campaign with the URL /#/game and invested 1 million in promotion budget. After the campaign ends, many users will still access /game through previously saved bookmarks, shared links, or SMS links. If a 404 is returned directly, the effect of that 1 million promotion is diminished.

The correct approach is to redirect /game to /result (the campaign results page), telling users "The campaign has ended, here is the final winners list."

// Configuration in App.jsx
<Route path="/game" element={
  <Navigate replace to="/result" />
} />

Why must replace be used?

User clicks a link from an SMS to enter /game:

Without replace (push mode):
  History stack: [SMS link] → [/game] → [/result]
  User clicks back → returns to /game → immediately redirected to /result again
  → User is trapped in an infinite loop, never able to go back to the SMS source page

Using replace:
  History stack: [SMS link] → [/result]
  User clicks back → returns to the SMS source page ✔

Further Optimization: Redirection with Parameters

// A more complete approach: carrying the activity ID for data statistics
<Route path="/game/:activityId" element={
  <Navigate replace to="/result" />
} />

// In the Result page, you can also get the original activity ID in a more advanced way
// For example, carrying state in Navigate:
<Route path="/game/:activityId" element={
  <GameRedirect />
} />

// Inside the GameRedirect component:
function GameRedirect() {
  const { activityId } = useParams();
  return <Navigate replace to="/result" state={{ fromActivity: activityId }} />;
}

4.9.2 Scenario 2: Homepage Path Normalization — /home/

During the evolution of many websites, the homepage path may change multiple times: /home, /index, /main, etc. To ensure SEO and user experience consistency, all old homepage paths should be redirected to the unified /.

// Configuration in App.jsx
<Route path="/home" element={
  <Navigate replace to="/" />
} />
<Route path="/index" element={
  <Navigate replace to="/" />
} />

Deep Principle: Search engine crawlers will consider /home and / as two different pages, leading to duplicate content penalties. Through 301 (server-side) or client-side redirection, search engines are informed that "these URLs all point to the same page." Although the content after # in HashRouter is not sent to the server (search engines typically do not index hash route paths), this is crucial in BrowserRouter mode.

4.9.3 Scenario 3: Conditional Redirection — Unauthenticated User Accesses /user/:id/login

This is the most complex but most common scenario. When an unauthenticated user accesses a page that requires login (like /user/123 profile page), they should be guided to the login page, and after successful login, automatically return to the page they originally wanted to visit.

User accesses /user/123 (not logged in)
       │
       ▼
Detected not logged in → Redirect to /login, and remember "user wanted to come to /user/123"
       │
       ▼
User enters username and password on /login → Login successful
       │
       ▼
Automatically jump back to /user/123 (not the homepage! This is the experience that meets user expectations)

Implementing this closed loop requires three components working together: ProtectRoute (route guard), Login (login page), Navigate (redirection instruction). The core mechanism is passing source information via location.state—this will be expanded in detail in sections 4.10 and 4.11 below.


4.10 Route Guard (ProtectRoute): The "Access Control System" for Permissions

4.10.1 Core Conclusion (Pyramid Peak)

A route guard is a "security check component" wrapped around the target page—it does not modify the URL structure but inserts an authentication check before rendering the target page: if logged in, allow access (render children); if not, intercept and redirect to the login page, while remembering "where the user came from" via location.state, laying the groundwork for the post-login redirect.

User accesses /pay (protected page)
       │
       ▼
ProtectRoute component renders
       │
       ├── isLogin === true  → Render {children} (i.e., <Pay />)
       │                       User uses payment function normally
       │
       └── isLogin === false → Return <Navigate to="/login" state={{ from: location }} />
                                User is sent to the login page, location info is saved in state

4.10.2 Complete Source Code and Line-by-Line Analysis

// src/ProtectRoute.jsx (27 lines total)
import {
    Navigate,     // Redirection component
    useLocation   // Get current route location information
} from 'react-router-dom';

const ProtectRoute = ({ children }) => {
    console.log(children, '-----');
    // ↑ children is the JSX element wrapped inside the <ProtectRoute> tag
    // In App.jsx: <ProtectRoute><Pay /></ProtectRoute>
    // children is the <Pay /> React element

    const location = useLocation();
    // ↑ useLocation() returns the complete location object of the current route:
    // {
    //   pathname: "/pay",          ← current path
    //   search: "",                ← query string
    //   hash: "",                  ← sub-hash within hash
    //   state: null,               ← route state (passed via navigate or Link's state)
    //   key: "default"             ← unique identifier
    // }

    // html5 local storage — domain sandbox
    // localStorage is a key-value store provided by the browser, data is shared under the same domain
    // Each domain has an independent localStorage sandbox (about 5MB), different domains cannot see each other's data
    const isLogin = localStorage.getItem('isLogin') === 'true';
    console.log(isLogin, 'isLogin');

    if (!isLogin) {
        // Not logged in → Redirect to login page
        // ★★★ Key Design: state={{ from: location }} ★★★
        // Pass the current location object (containing pathname="/pay") as state
        // After successful login, the Login component can read from from state to achieve the redirect back
        return <Navigate to="/login" replace state={{ from: location }} />;
    }

    return (
        <>
            ProtectRoute:
            {children}  {/* Logged in, allow access! Render the protected page component */}
        </>
    );
};
export default ProtectRoute;

4.10.3 The Design Wisdom of the children Pattern

ProtectRoute uses React's children prop pattern, rather than writing judgment logic directly in the route configuration:

// ✔ Current design (children pattern) — ProtectRoute is a universal component
<Route path="/pay" element={
  <ProtectRoute>
    <Pay />
  </ProtectRoute>
} />

// Can be easily reused for any page that needs protection:
<Route path="/settings" element={
  <ProtectRoute>
    <Settings />
  </ProtectRoute>
} />
<Route path="/orders" element={
  <ProtectRoute>
    <Orders />
  </ProtectRoute>
} />

Why is this better than "writing authentication logic in every page component"?

❌ Writing authentication in every page component (code duplication, easy to miss):
function Pay() {
  const isLogin = localStorage.getItem('isLogin') === 'true';
  if (!isLogin) return <Navigate to="/login" />;
  // ... business logic
}

function Settings() {
  const isLogin = localStorage.getItem('isLogin') === 'true';
  if (!isLogin) return <Navigate to="/login" />;
  // ... business logic
}
// Every page needs to write authentication, if forgotten it's a security vulnerability

✔ Using ProtectRoute to wrap (separation of concerns, won't miss):
// Authentication logic is centralized in the ProtectRoute component
// Page components only care about their own business logic
// When adding a new protected page, just wrap it in the route configuration

4.10.4 Deep Analysis of state={{ from: location }}

This is the most ingenious line of code in the entire login redirect mechanism:

return <Navigate to="/login" replace state={{ from: location }} />;

What is state?

state is React Router's "implicit channel" for passing data outside the URL. It does not appear in the URL, will not show in the browser address bar, and will not be sent to the server. It is stored via the browser's History API history.state mechanism and is only visible on the client side.

Why pass the entire location object instead of just pathname?

// ❌ Only pass pathname — loses query parameters and hash
state={{ from: location.pathname }}   // Only gets "/pay", loses ?coupon=123

// ✔ Pass the entire location — retains complete information
state={{ from: location }}            // Gets { pathname: "/pay", search: "?coupon=123", ... }

Real-world scenario example: A user accesses the payment page /pay?coupon=SUMMER50 via a link with a coupon parameter and is intercepted to the login page. After successful login, they should return to /pay?coupon=SUMMER50 instead of /pay. Passing the entire location object ensures query parameters are not lost.

The role of replace again:

Using replace in ProtectRoute means "replace /pay's position in the history stack with /login":

User directly accesses /pay (not logged in):
  History stack: [/pay]  →  ProtectRoute executes replace →  [/login]
  
After user successfully logs in navigate('/pay'):
  History stack: [/pay]
  
At this point, user clicks back → returns to the page before login (maybe the homepage)
Instead of returning to /login (intercepted by ProtectRoute again → sent back to /login → infinite loop)

4.11 Login Authentication and Redirect Mechanism: The Complete Closed Loop from Interception to Access

4.11.1 Core Conclusion (Pyramid Peak)

Login is not an isolated page jump, but a complete state machine of "intercept → temporarily store intent → authenticate → restore intent." location.state is the "memory carrier" of this state machine—ProtectRoute uses it to remember "where the user wants to go" during interception, and the Login component uses it to restore "the user's original destination" after successful login.

Complete Closed Loop (Overview):

  User accesses /pay (not logged in)
       │
       ▼
  ProtectRoute detects !isLogin
       │
       ├── Save location = { pathname: "/pay", search: "" } to state.from
       └── <Navigate replace to="/login" state={{ from: location }} />
       │
       ▼
  Login component renders
       │
       ├── useLocation().state?.from?.pathname → "/pay"
       │   (Reads from state where the user originally wanted to go)
       │
       ├── User enters username and password → validates → localStorage.setItem('isLogin', 'true')
       │
       └── navigate("/pay", { replace: true })
       │
       ▼
  URL becomes /pay → Routes re-match → ProtectRoute executes again
       │
       ├── isLogin === true → Allow access!
       └── Render <Pay />

4.11.2 Complete Source Code and Line-by-Line Analysis of the Login Component

// src/pages/Login/index.jsx (57 lines total)
import {
    useNavigate,   // Imperative navigation Hook
    useLocation,   // Read current route location (including state)
} from 'react-router-dom';

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

    // ★★★ Core: Extract "where the user originally wanted to go" from state ★★★
    // location.state can have three situations:
    // 1. Intercepted from ProtectRoute: location.state = { from: { pathname: "/pay", ... } }
    // 2. Passed from Link state: location.state = { from: { pathname: "/user/123", ... } }
    // 3. Direct access to /login: location.state = null (no state)
    //
    // The optional chaining operator ?. is ES11 syntax:
    // location.state?.from?.pathname is equivalent to:
    //   location.state && location.state.from && location.state.from.pathname
    // If any link in the chain is null/undefined, the entire expression returns undefined (no error)
    const from = location.state?.from?.pathname || '/';
    //                              Default to homepage ↑
    // If the user directly accesses /login (no source info), go to homepage after login

    console.log(from, 'from');

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

        // Native form data object — no need for useState to manage each field
        const formData = new FormData(e.currentTarget);
        // FormData is an HTML5 standard API, e.currentTarget points to the <form> DOM element
        const username = formData.get("username");
        const password = formData.get("password");

        if (!username || !password) {
            alert("Please enter username and password");
            return;
        }

        // Hardcoded username/password verification (production should call backend API)
        if (username === "admin" && password === "123456") {
            // ★ Write login status to localStorage
            localStorage.setItem('isLogin', 'true');

            // ★★★ Key: replace: true ★★★
            // Replace the /login record in history with the target page's record
            //
            // Why replace?
            // If replace is not used, history stack becomes: [/pay before interception] → [/login] → [/pay]
            // User clicks back on /pay → returns to /login → finds already logged in → auto-jumps to /pay again → infinite loop
            //
            // Using replace, history stack becomes: [/pay before interception] → [/pay]
            // User clicks back on /pay → returns to the page before interception ✔
            navigate(from, { replace: true });
        } else {
            alert("Incorrect username or password");
        }
    }

    return (
        <form onSubmit={handleSubmit}>
            <h1>Login</h1>
            <input
                name="username"
                placeholder="Please enter username"
                required
            />
            <input
                name="password"
                placeholder="Please enter password"
                type="password"
                required
            />
            <button type="submit">Login</button>
            <p>This is the login page</p>
        </form>
    );
};
export default Login;

4.11.3 Three Value Scenarios for the from Variable

const from = location.state?.from?.pathname || '/';
User Action location.state from Value Post-Login Redirect
Access /pay intercepted by ProtectRoute { from: { pathname: "/pay", ... } } "/pay" Back to payment page
Access /user/123 intercepted by conditional redirect { from: { pathname: "/user/123", ... } } "/user/123" Back to user detail page
Direct access to /login (e.g., clicked "Login" in navbar) null or undefined "/" (default) Back to homepage

4.11.4 Why Use FormData Instead of Controlled Components?

// ✔ Current approach: Uncontrolled — using FormData native API
function handleSubmit(e) {
    const formData = new FormData(e.currentTarget);
    const username = formData.get("username");
}

// ❌ Alternative approach: Controlled — one useState per field
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// 2 fields need 2 states + 2 onChanges, what about 10 fields?

Reasons for choosing FormData:

4.11.5 Re-emphasizing replace: true in the Login Scenario

This is the most easily overlooked detail in the login flow, but it directly impacts user experience:

login post-login redirect:

Without replace (push mode):
  History stack: [Homepage] → [/pay intercepted] → [/login] → [/pay]
                              ↑ This was replaced (ProtectRoute's replace)
                                        ↑ This was pushed in (navigate defaults to push)
  User clicks back on /pay:
    First back → /login → isLogin=true → detects logged in → auto-jumps back to /pay
    Second back → /login → jumps back to /pay again
    Third back → ... ← User is trapped! Can never go back!

Using replace:
  History stack: [Homepage] → [/pay]
  User clicks back on /pay:
    Directly back to homepage ✔ Perfect user experience

4.11.6 Considerations for Using localStorage as Login State Storage

// Write
localStorage.setItem('isLogin', 'true');

// Read
const isLogin = localStorage.getItem('isLogin') === 'true';

// Clear (logout)
localStorage.removeItem('isLogin');

Why use localStorage instead of sessionStorage?

Storage Method Lifecycle Applicable Scenario
localStorage Permanent (unless manually cleared) "Remember me"—stay logged in after closing and reopening the browser
sessionStorage Cleared when tab is closed Sensitive operations—automatically logout when tab is closed

Important Warning for Production Environments: This project uses localStorage to store login status for teaching demonstration purposes only. In a production environment, you should never rely solely on localStorage for permission control, because:

  1. Users can manually modify localStorage (execute localStorage.setItem('isLogin', 'true') in DevTools to bypass)
  2. localStorage is not automatically sent with HTTP requests, the backend cannot verify it
  3. Real permission control must be verified by the backend through token (JWT) / session, front-end route guards are just the "first line of defense" (improving user experience), real security is on the backend

4.12 Payment Page (Pay): A Complete Example of a Protected Resource

4.12.1 Core Conclusion (Pyramid Peak)

The Pay component is the "protected object" of the route guard—it contains no authentication logic, focusing only on business functions (selecting payment method, simulating payment, logging out). Its security is entirely guaranteed by the outer <ProtectRoute>. This "separation of concerns" design makes business components and permission logic independent, testable, and reusable.

4.12.2 Complete Source Code and Key Logic

// src/pages/Pay/index.jsx (113 lines total)
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';

const Pay = () => {
    const navigate = useNavigate();
    const [payMethod, setPayMethod] = useState('wechat');   // Payment method
    const [paying, setPaying] = useState(false);            // Paying state
    const [paySuccess, setPaySuccess] = useState(false);    // Payment success state

    // Simulated order data
    const order = {
        id: 'ORD' + Date.now(),
        name: 'Tian Long Ba Bu RAG Assistant - Annual Membership',
        price: 99.00,
    };

    // Simulate payment flow
    const handlePay = () => {
        setPaying(true);         // Enter paying state → button grays out, shows "Paying..."
        setTimeout(() => {
            setPaying(false);    // Payment complete
            setPaySuccess(true); // Switch to success page
            setTimeout(() => {
                navigate('/', { replace: true });  // Auto-redirect to homepage after 2 seconds
            }, 2000);
        }, 1500);  // Simulate 1.5 seconds payment processing
    };

    // Logout
    const handleLogout = () => {
        localStorage.removeItem('isLogin');  // Clear login flag
        navigate('/login');                   // Navigate to login page
    };

    // UI after payment success
    if (paySuccess) {
        return (
            <div style={{ textAlign: 'center', padding: 40 }}>
                <h2>Payment Successful</h2>
                <p>Order ID: {order.id}</p>
                <p>Amount: ¥{order.price}</p>
                <p>Redirecting to homepage...</p>
            </div>
        );
    }

    // Normal payment UI
    return (
        <div style={{ padding: 20, maxWidth: 480, margin: '0 auto' }}>
            <h2>Payment Page</h2>
            {/* Order Information */}
            <div style={{ border: '1px solid #ddd', padding: 16, borderRadius: 8, marginBottom: 16 }}>
                <h3>{order.name}</h3>
                <p>Order ID: {order.id}</p>
                <p style={{ fontSize: 24, color: '#e4393c', fontWeight: 'bold' }}>
                    ¥{order.price.toFixed(2)}
                </p>
            </div>
            {/* Payment Method Selection */}
            <div style={{ marginBottom: 16 }}>
                <h3>Select Payment Method</h3>
                <label><input type="radio" name="payMethod" value="wechat"
                    checked={payMethod === 'wechat'}
                    onChange={(e) => setPayMethod(e.target.value)} /> WeChat Pay</label>
                <label><input type="radio" name="payMethod" value="alipay"
                    checked={payMethod === 'alipay'}
                    onChange={(e) => setPayMethod(e.target.value)} /> Alipay</label>
            </div>
            {/* Pay Button */}
            <button onClick={handlePay} disabled={paying}
                style={{ backgroundColor: paying ? '#ccc' : '#e4393c', /* ... */ }}>
                {paying ? 'Paying...' : `Confirm Payment ¥${order.price.toFixed(2)}`}
            </button>
            {/* Logout Button */}
            <button onClick={handleLogout} style={{ /* ... */ }}>
                Logout
            </button>
        </div>
    );
};
export default Pay;

4.12.3 Component State Machine Design

The Pay component has three internal states, forming a simple state machine:

Initial state (paying=false, paySuccess=false)
  │
  ├── User clicks "Confirm Payment"
  │     │
  │     ▼
  │   Paying (paying=true, paySuccess=false)
  │   UI: Button grays out showing "Paying...", cannot be clicked again (disabled)
  │     │
  │     │ setTimeout 1500ms
  │     │
  │     ▼
  │   Payment Successful (paying=false, paySuccess=true)
  │   UI: Shows success message + "Redirecting to homepage..."
  │     │
  │     │ setTimeout 2000ms
  │     │
  │     ▼
  │   navigate('/') → Back to homepage

  ├── User clicks "Logout"
  │     │
  │     ▼
  │   localStorage.removeItem('isLogin')
  │   navigate('/login')
  │     │
  │     ▼
  │   ProtectRoute detects !isLogin → Intercepts again → Sends back to login page

4.12.4 Route Behavior After Logout

const handleLogout = () => {
    localStorage.removeItem('isLogin');
    navigate('/login');
};

After logout, navigate to /login. Since isLogin in localStorage has been cleared, the Login page displays the login form normally. At this point:


4.13 Complete Business Flow Integration: Full Link Tracing from Payment Intent to Payment Success

4.13.1 Core Conclusion (Pyramid Peak)

Front-end route permission control is not an isolated feature point, but a complete closed loop completed by four components working together: ProtectRoute (access control), Login (authentication), Pay (business), and Navigate (dispatch). The key to understanding this closed loop lies in tracing the flow of location.state and localStorage, the two "implicit data channels," through each link.

4.13.2 Timeline Tracing

T=0ms    User status: Not logged in, isLogin does not exist in localStorage
         User enters URL in browser: http://localhost:5173/#/pay

T=1ms    HashRouter initializes, pathname = "/pay"

T=2ms    Routes start matching:
         Iterate Route:
           path="/"         → No match
           path="/about"    → No match
           path="/user/:id" → No match
           path="/products" → No match
           path="/old-path" → No match
           path="/login"    → No match
           path="/pay"      → ✔ Match!
         
         Render element={<ProtectRoute><Pay /></ProtectRoute>}

T=3ms    ProtectRoute component executes:
           useLocation() → { pathname: "/pay", search: "", state: null, ... }
           localStorage.getItem('isLogin') → null → isLogin = false
           
           !isLogin === true → Enter interception branch
           return <Navigate replace to="/login"
                           state={{ from: { pathname: "/pay", ... } }} />

T=4ms    Navigate component renders → Triggers navigation:
           hash changes to '#/login'
           history.replaceState (replace mode)
           Writes state to history record

T=5ms    hashchange triggers → HashRouter updates → React re-renders

T=6ms    Routes re-match:
           path="/login" → ✔ Match!
           Render <Login />

T=7ms    Login component executes:
           location = useLocation()
           location.state = { from: { pathname: "/pay", ... } }
           from = location.state?.from?.pathname || '/'
           from = "/pay"
           console.log("/pay", 'from')  // Visible in DevTools

T=8ms    User sees login form, enters username and password

T=5000ms User clicks "Login" button
         
T=5001ms handleSubmit executes:
           e.preventDefault()
           formData.get("username") → "admin"
           formData.get("password") → "123456"
           Validation passes!
           localStorage.setItem('isLogin', 'true')
           navigate("/pay", { replace: true })

T=5002ms hash changes to '#/pay' (replace mode, replaces /login from history stack)
         History stack current state: [Homepage] → [/pay]

T=5003ms hashchange triggers → HashRouter updates → React re-renders

T=5004ms Routes re-match /pay → Render ProtectRoute again

T=5005ms ProtectRoute executes again:
           localStorage.getItem('isLogin') → 'true' → isLogin = true
           
           isLogin === true → Take normal branch!
           return <>ProtectRoute: {children}</>
           children = <Pay />
           Render <Pay />

T=5006ms User sees payment page:
           "Payment Page"
           "Tian Long Ba Bu RAG Assistant - Annual Membership"
           "¥99.00"
           [WeChat Pay] [Alipay]
           [Confirm Payment ¥99.00]
           [Logout]

T=8000ms User selects "Alipay", clicks "Confirm Payment"

T=8001ms handlePay executes:
           setPaying(true) → Button grays out, shows "Paying..."
           setTimeout 1500ms

T=9500ms setTimeout callback executes:
           setPaying(false)
           setPaySuccess(true)

T=9501ms Component re-renders, paySuccess === true:
           Shows "Payment Successful"
           "Order ID: ORD1691..."
           "Amount: ¥99.00"
           "Redirecting to homepage..."
           setTimeout 2000ms

T=11500ms setTimeout callback executes:
            navigate('/', { replace: true })

T=11501ms hash changes to '#/' → React re-renders → Matches path="/" → Renders <Home />
          User returns to homepage, the entire flow ends.

4.13.3 Key Data Flow Summary

                    localStorage               location.state
                    ────────────               ──────────────
ProtectRoute intercepts:  Reads isLogin               Writes { from: location }
                    (Determines if access allowed)     (Remembers "where the user came from")

Login successful:    Writes isLogin='true'         Reads from.pathname
                    (Marks as logged in)               (Knows "where to go back")

Pay logout:          Deletes isLogin               (Not involved)
                    (Clears login state)

Two "implicit channels" for data throughout the process:
  - localStorage: Browser storage, persists across pages/refreshes, used to determine login status
  - location.state: Route state, only exists in the history stack of the current navigation session, does not persist