How React Router Turns a URL Change into a DOM Update Without a Page Refresh
The runnable source code is here
I. Core Conclusion (The Tip of the Pyramid)
The essence of React Router is to use JavaScript to take over browser URL changes, enabling seamless switching between multiple "pages" without refreshing the page—this is the front-end routing mechanism of a SPA (Single Page Application). It transforms the traditional back-end routing pattern of "URL → server-side render new page" into a "URL → client-side match component → partial DOM update" pattern, thereby eliminating page white screens and delivering a smooth experience close to that of a native application.
This project is based on Vite 8 + React 19 + react-router-dom v7.18. Starting from a create-vite scaffold project, it integrates front-end routing step by step, fully implementing eight core capabilities: Hash routing strategy, declarative route configuration table, dynamic route parameters, nested routing (including second-level sub-routes), route redirection, 404 wildcard fallback, React.lazy() lazy loading optimization, and useParams parameter passing. Below, from top to bottom, starting from the fundamental question of "why front-end routing exists," we will dissect every line of code layer by layer.
II. Historical Evolution: Why Do We Need Front-End Routing? (Second Layer of the Pyramid—Background and Motivation)
2.1 Traditional Back-End Routing Model (Multi-Page Application MPA)
Before the rise of Ajax and front-end frameworks, web applications uniformly adopted the back-end routing architecture. Back-end routing means: URL path parsing and page rendering are all completed on the server side.
User clicks <a href="/user/123">User Details</a>
│
▼
Browser sends a GET /user/123 request to the server
│
▼
Server receives request → parses URL → queries database → renders HTML template → returns complete HTML page
│
▼
Browser receives new HTML → white screen → re-parses → re-renders → page displays
This process has three fatal flaws:
- White Screen Problem: Every time a page jumps, the browser discards the current DOM tree and rebuilds everything. When the network is slow or the page is complex, the user sees a noticeable white flash (Flash of White).
- Redundant Transmission: The HTML for each page contains the same common parts like headers, navigation bars, and footers. This redundant data is re-downloaded with every jump.
- State Loss: A form half-filled by the user on page A, an expanded menu, or a scroll position—all are lost upon clicking a link to jump to page B and then returning (unless manually persisted).
This is what the readme.md mentions: "Previously, back-end routing support was needed, which was traditional, slow, caused a white flash, and provided a poor experience."
2.2 RESTful Thinking and "Everything is a Resource"
Before discussing front-end routing, it's necessary to understand the RESTful (Representational State Transfer) architectural style. The first line of the readme.md points out: "restful everything is a resource".
The core proposition of RESTful is: A URL should not be a "verb" but a "noun"—each URL represents a Resource, and HTTP methods (GET/POST/PUT/DELETE) represent operations on the resource.
❌ Traditional URL Design (Verb-oriented, operation-facing):
/getUser?id=123
/deleteProduct?id=456
/createOrder
✔ RESTful URL Design (Noun-oriented, resource-facing):
GET /user/123 → Get information for user 123
PUT /user/123 → Update information for user 123
DELETE /user/123 → Delete user 123
POST /user → Create a new user
GET /product/456 → Get information for product 456
Why is RESTful thinking closely related to front-end routing? Because React Router's path parameter design (/user/:id) naturally expresses "resources" in the URL. The 123 in /user/123 is not an operation name but a unique identifier for a resource. The URL structure of front-end routing naturally conforms to RESTful specifications—each path corresponds to a page view, and path parameters correspond to a resource's ID.
2.3 Separation of Front-End and Back-End and the Birth of SPA
With the maturation of Ajax technology, a new architecture emerged: separation of front-end and back-end.
Traditional Model: Server = Routing + Business Logic + HTML Rendering
Separated Model: Server = Pure Data API (JSON), Front-end = Routing + UI Rendering
Under this new architecture, the browser first loads an almost empty index.html and an app.js bundled with all the logic. All subsequent page switching is done entirely by JavaScript on the client side. This is a SPA (Single Page Application)—there is only one HTML page, and all "page switching" is essentially JavaScript partially replacing the DOM.
The core technology of a SPA is front-end routing.
2.4 The Principle of Hash Routing: Anchor Links + hashchange Event
The readme.md writes: "hash anchor link, changing the url hash part does not refresh the page, hashchange". These eighteen words precisely summarize the underlying principle of Hash routing.
Let's dissect it step by step:
Step 1: Understanding the Original Use of # in URLs
In the early design of HTML, # was used for "in-page anchors". For example:
<a href="#section2">Jump to Section 2</a>
...
<h2 id="section2">Section 2</h2>
Clicking this link does not send any request to the server; instead, it scrolls directly within the current page to the element with id="section2". The # part of a URL and everything after it (called the fragment/hash) is never sent to the server.
Let's verify: Execute location.hash in the browser console:
Current URL: http://localhost:5173/#/user/123
Execute location.hash → "#/user/123"
Execute location.href → "http://localhost:5173/#/user/123"
Request sent to server → GET http://localhost:5173/ (Everything after # is truncated by the browser)
Step 2: The hashchange Event
HTML5 provides the hashchange event—when the hash part of the URL changes, the browser triggers this event. The key point: hash changes do not cause a page refresh.
// Minimal Hash routing implementation in pure native JS (6 lines total)
window.addEventListener('hashchange', () => {
const path = location.hash.slice(1) || '/'; // Remove the leading #
console.log('Current route:', path);
// Decide which "page" to display based on path
});
// Trigger a route change
location.hash = '#/about'; // URL becomes /#/about, page does not refresh, triggers hashchange
Step 3: React Router's Encapsulation
HashRouter does the above thing at its core, but it encapsulates the native hashchange event into React's reactive system:
// Simplified pseudo-code inside HashRouter (core logic of react-router-dom source code)
function HashRouter({ children }) {
const [location, setLocation] = useState(() => ({
pathname: window.location.hash.slice(1) || '/',
}));
useEffect(() => {
const handleHashChange = () => {
setLocation({
pathname: window.location.hash.slice(1) || '/',
});
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
// Pass location down via Context
return (
<RouterContext.Provider value={{ location, navigate }}>
{children}
</RouterContext.Provider>
);
}
Core chain summary:
location.hash = '#/about'
│
▼
Browser triggers hashchange event
│
▼
handleHashChange inside HashRouter's useEffect executes
│
▼
Calls setLocation() to update React state
│
▼
React re-renders → Routes component re-matches with the new pathname
│
▼
Matches <Route path="/about" element={<About />} /> → Renders <About />
│
▼
DOM updates partially, page did not refresh!
2.5 Complete Comparison of Hash Routing vs History Routing
| Dimension | HashRouter (#/path) |
BrowserRouter (/path) |
|---|---|---|
| URL Form | a.com/#/user/123 |
a.com/user/123 |
| Underlying API | location.hash + hashchange event |
HTML5 History API: pushState() / replaceState() + popstate event |
| Server Request | Content after # is never sent to the server, safe on refresh |
Browser sends the full path to the server on refresh, requires server-side fallback configuration |
| Server Config | Zero configuration | Must configure rewrite rules (e.g., Nginx try_files), otherwise 404 on refresh |
| SEO | Poorer (search engines typically ignore content after #) |
Better (full SEO achievable with SSR/SSG) |
| URL Aesthetics | Has # symbol, looks slightly "dirty" |
Clean, close to traditional multi-page application URLs |
| Compatibility | Excellent (IE8+) | IE10+ |
| Applicable Scenarios | Admin panels, internal tools, demo projects | User-facing C-end products (paired with SSR) |
Reason for choosing HashRouter in this project: As a demo teaching project, Hash routing requires no server-side configuration, works immediately after npm run dev, and naturally prevents 404 errors on refresh. In a production environment, user-facing products typically switch to BrowserRouter.
III. Architecture Panorama (Third Layer of the Pyramid—System Design)
3.1 Three Pillars
The entire React Router system is supported by three pillars, none of which can be missing:
| Pillar | Corresponding Component/API | Responsibility |
|---|---|---|
| Routing Container | HashRouter (this project) / BrowserRouter |
Listens for URL changes, manages routing state, passes it down via React Context |
| Route Configuration Table | Routes + Route |
Declaratively defines the mapping between URL paths and page components |
| Navigation System | Link / NavLink / Navigate / useNavigate / useParams |
Triggers route jumps, passes URL parameters, gets current route information |
3.2 Project Directory Structure and Responsibility Division
react-router/ ← Root directory generated by Vite scaffold
│
├── index.html ← The single HTML file of the SPA
│ └── <div id="root"></div> ← React's mount point (empty shell)
│ └── <script src="/src/main.jsx"> ← Application entry point
│
├── package.json ← Project metadata + dependency declarations
│ ├── react: ^19.2.7 ← UI framework
│ ├── react-dom: ^19.2.7 ← DOM renderer
│ ├── react-router-dom: 7.18.2 ← Front-end routing library
│ └── vite: ^8.1.1 ← Build tool
│
├── vite.config.js ← Vite configuration (plugin system)
│ └── plugins: [react()] ← @vitejs/plugin-react (uses Oxc compiler)
│
└── src/ ← All source code
├── main.jsx ← Application startup entry: createRoot + render
├── App.jsx ← ★ Route configuration core (the most important file)
├── App.css ← App-level styles
├── index.css ← Global styles (CSS variables, dark mode, responsive)
│
├── component/ ← Shared components
│ └── Navigation.jsx ← Global navigation bar (Link component usage example)
│
├── pages/ ← Page-level components (one for each route)
│ ├── Home/index.jsx ← Home page → path="/"
│ ├── About/index.jsx ← About page → path="/about"
│ ├── User/index.jsx ← User detail page → path="/user/:id"
│ └── NotFound/index.jsx ← 404 page → path="*"
│
└── Products/ ← Product module (demonstrates nested routing)
├── index.jsx ← Product list (parent route component, contains <Outlet />)
├── ProductDetail.jsx ← Product detail (child route) → path=":productId"
├── Detail/index.jsx ← Product detail (alternative implementation)
└── New/index.jsx ← New product (child route) → path="new"
3.3 Component Hierarchy Tree and Data Flow
index.html
└── <div id="root">
└── main.jsx: createRoot(root).render(
└── <StrictMode>
└── <App /> ← The starting point of everything
└── <HashRouter> ← Routing container, provides location context
└── <Suspense fallback="Wait for me..."> ← Lazy loading boundary
├── <Navigation /> ← Navigation bar (Link component)
└── <div id="container">
└── <Routes> ← Route matching engine
├── <Route path="/" → <Home />>
├── <Route path="/about" → <About />>
├── <Route path="/user/:id" → <User />>
├── <Route path="/products" → <Products />>
│ ├── <Route path=":productId" → <ProductDetail />>
│ └── <Route path="new" → <NewProduct />>
├── <Route path="/old-path" → <Navigate />>
└── <Route path="*" → <NotFound />>
Data flow direction:
- Top-down (unidirectional data flow): HashRouter passes routing state like
location,navigatedown via React Context - Bottom-up (Hooks reading): Child components consume routing state via Hooks like
useParams(),useLocation() - Horizontal trigger (event-driven): User clicks Link or calls navigate() → modifies URL → hashchange event → HashRouter updates state → React re-renders
IV. Line-by-Line Dissection of the Eight Core Mechanisms (Fourth Layer of the Pyramid—Most Detailed Code Analysis)
4.1 HashRouter: The Root Container for Front-End Routing
4.1.1 Code
// App.jsx lines 5-11
import {
HashRouter as Router, // Alias HashRouter as Router
Routes, // Route configuration array container
Route, // Single route rule
Navigate, // Redirect component
} from 'react-router-dom';
Here, the as Router alias import is used. The benefit is: if you need to switch from Hash mode to History mode in the future, you only need to change one word:
// Switching from Hash mode to History mode, only one line changes
import { BrowserRouter as Router } from 'react-router-dom';
// ^^^^^^^^^^^^^ All other code remains completely unchanged
// App.jsx line 31
<Router>
{/* All routing-related components must be placed inside Router, otherwise they cannot access the routing Context */}
</Router>
4.1.2 Underlying Implementation Details of Router
Section 2.4 earlier showed the simplified pseudo-code of HashRouter. Here, we delve into a few key details:
(1) Why must Router wrap all routing components at the outermost level?
Because HashRouter internally uses React Context to pass routing information downwards. All react-router-dom components and Hooks, such as Routes, Route, Link, useParams, depend on this Context. If they are placed outside Router, an error will be thrown directly:
Error: useLocation() may be used only in the context of a <Router> component.
(2) What data exactly is in the Context passed by HashRouter?
// Data structure provided by HashRouter's Context (simplified version)
{
location: {
pathname: "/user/123", // Current path (with # removed)
search: "?tab=profile", // Query string
hash: "", // Hash within the hash (rarely used)
state: null, // Route state (extra data passed via navigate)
key: "abc123", // Unique identifier to distinguish different visits to the same path
},
navigate: function(to, options) {
// options: { replace, state, ... }
// Internally calls location.hash = '#/xxx' or history.pushState()
},
params: {}, // URL parameters (:id, etc., filled by nested Route after matching)
}
(3) Route parsing during initialization
When a user first opens the page, HashRouter's initialization flow:
1. Reads window.location.hash
e.g., "#/user/123" → pathname = "/user/123"
If hash is empty → pathname = "/"
2. Stores the parsed pathname into React state
3. Registers the hashchange event listener
4. On first render, puts { location, navigate } into Context
5. Child component Routes receives location.pathname = "/user/123"
Starts matching <Route path="/user/:id"> → hit → extracts parameter { id: "123" }
Renders <User /> and passes { id: "123" } to useParams()
4.2 Routes + Route: Declarative Route Configuration Table (The Most Core Part of the Entire System)
4.2.1 Complete Code with Line-by-Line Comments
// App.jsx lines 36-56
<Routes>
{/* ① Root path "/" —— Home page */}
<Route path="/" element={<Home />} />
{/* ② Static path "/about" —— About page */}
<Route path="/about" element={<About />} />
{/* ③ Dynamic path "/user/:id" —— User detail page */}
{/* The colon :id indicates this is a dynamic parameter segment, matching any value */}
<Route path="/user/:id" element={<User />} />
{/* ④ Nested route "/products" —— This is the parent route */}
<Route path="/products" element={<Products />}>
{/* ④a Child route: /products/123 → Product detail */}
{/* Note: The child route's path is relative, no need to write the /products/ prefix */}
<Route path=":productId" element={<ProductDetail />} />
{/* ④b Child route: /products/new → New product */}
<Route path="new" element={<NewProduct />} />
</Route>
{/* ⑤ Redirect route "/old-path" —— Old URL permanently redirects to new URL */}
{/* replace means replacing the history entry, so the user won't return to this path by clicking "back" */}
<Route path="/old-path" element={
<Navigate replace to="/products/new" />
} />
{/* ⑥ Wildcard route "*" —— 404 fallback, must be placed last */}
{/* The asterisk * is a greedy match, matching any path not hit by the preceding routes */}
<Route path="*" element={<NotFound />} />
</Routes>
4.2.2 Routes Matching Algorithm
Internally, Routes is not just a simple traversal; it has a complete set of matching priorities:
Matching rule priority (from high to low):
1. Exact match takes precedence
Path "/about" only matches "/about", not "/about/" (strict mode)
Path "/" only matches "/", not "/about"
2. Static segments take precedence over dynamic segments
"/products/new" takes precedence over "/products/:productId"
When URL = "/products/new", it matches path="new" not path=":productId"
Because "new" is a static string, ":productId" is a wildcard
3. Dynamic segments (:id) take precedence over wildcards (*)
"/user/:id" takes precedence over "/user/*"
4. Deeper path depth means higher priority
"/a/b/c" takes precedence over "/a/b"
5. * is the fallback: only hit when all preceding routes fail to match
Actual matching example:
URL = "/products/new"
Routes matching process:
├── path="/" → No match (path too short)
├── path="/about" → No match (path different)
├── path="/user/:id" → No match (prefix different)
├── path="/products" → Match! (prefix match)
│ ├── path=":productId" → No match (the part after prefix is "new", but check here first)
│ └── path="new" → ✔ Match! Exact match
│ Final render: <Products /> wrapping <NewProduct />
└── (Match found, stop checking subsequent routes)
4.2.3 Detailed Explanation of Route's element Property
// The element property receives a React element (JSX), not the component itself
<Route path="/" element={<Home />} />
// ^^^^^^^^ This is <Home /> JSX element, not the Home function
// ❌ Incorrect usage:
<Route path="/" element={Home} /> // Home is a function, not a JSX element
<Route path="/" component={Home} /> // Old syntax from react-router v5, removed in v6+
// ✔ Correct usage:
<Route path="/" element={<Home />} />
<Route path="/" element={<Home title="Homepage" />} /> // Can directly pass props
Why did v6 change to element instead of v5's component?
The main reason is that element is more flexible: you can directly pass an inline JSX, not limited to components; you can pass different props at any time; it aligns more with React's "everything is a JSX element" philosophy.
4.3 Dynamic Route Parameters: :id Syntax and useParams Hook
4.3.1 Detailed Explanation of :parameterName Syntax
Dynamic route parameters are a key design for front-end routing to mimic RESTful style. Use a colon in the path to define the "position of a variable":
Path Pattern Matching URL Example Extracted Parameters
/user/:id /user/123 { id: "123" }
/user/:id /user/xiao { id: "xiao" }
/product/:productId /product/42 { productId: "42" }
/blog/:year/:month /blog/2024/03 { year: "2024", month: "03" }
/user/:id/profile /user/123/profile { id: "123" }
Syntax details:
- The identifier after
:is the parameter's key name, and the matched value is the key value - The parameter value defaults to a non-empty string, matching the part of the URL path between two
/ - Multiple parameters can be used in one path:
/user/:userId/post/:postId - A parameter can only match one "segment" of the path, i.e., the content between two
/ - To match multiple segments, use
*:/files/*matches/files/a/b/c
4.3.2 Complete Usage of useParams
// src/pages/User/index.jsx (14 lines total)
import { useParams } from 'react-router-dom';
function User() {
const { id } = useParams();
// useParams() returns an object containing all dynamic parameters matched by the current URL
// URL = /user/123 → useParams() = { id: "123" }
// URL = /user/abc → useParams() = { id: "abc" }
return (
<>
<h2>User</h2>
<p>User ID: {id}</p>
</>
);
}
export default User;
// src/Products/ProductDetail.jsx (13 lines total) —— useParams in nested routing
import { useParams } from 'react-router-dom';
function ProductDetail() {
const { productId } = useParams();
// URL = /products/42 → useParams() = { productId: "42" }
// Note: The parameter name comes from the Route's path=":productId", not path="/products/:productId"
return (
<>
<h3>Product Detail</h3>
<p>Product ID: {productId}</p>
</>
);
}
export default ProductDetail;
4.3.3 Implementation Principle of useParams
// Simplified source code logic for useParams
function useParams() {
// Read the match result from the nearest Route Context
const match = useContext(RouteContext);
// match.params is filled by the path matcher during the route matching phase
// For example, URL "/user/123" matches path="/user/:id"
// The matcher extracts id="123" via regex, storing it in match.params = { id: "123" }
return match.params;
}
Key point: useParams reads the match result of the nearest <Route> to the current component. In nested routing scenarios, child components read the params of the child Route, and parent components read the params of the parent Route, without interfering with each other.
// Inside the Products component (parent route component)
function Products() {
const params = useParams(); // {}
// The parent route's path is "/products", which has no dynamic parameters, so it's an empty object
}
// Inside the ProductDetail component (child route component)
function ProductDetail() {
const params = useParams(); // { productId: "42" }
// The child route's path is ":productId", which matched a parameter
}
4.3.4 Dynamic Parameters and Real-World Business Scenarios
In real projects, useParams is often combined with useEffect to request data based on URL parameters:
// A common pattern in real business (not implemented in this project, but worth knowing)
import { useParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
function UserProfile() {
const { id } = useParams();
const [user, setUser] = useState(null);
useEffect(() => {
// When the id in the URL changes (e.g., navigating from /user/123 to /user/456)
// useEffect re-executes to request data for the new user
fetch(`/api/user/${id}`)
.then(res => res.json())
.then(setUser);
}, [id]); // id is the dependency; when id changes, re-request
if (!user) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
4.4 Nested Routing: Parent Route Framework + Child Route Content
4.4.1 Why Do We Need Nested Routing?
Consider the UI layout of a product management module:
┌────────────────────────────────────────────┐
│ Product Management ← Title bar (shared by all sub-pages) │
│ [Product List] [New Product] [Detail] ← Nav Tabs (shared) │
├────────────────────────────────────────────┤
│ │
│ This is the changing content area: │
│ - Click "Product List" → Show product table │
│ - Click "New Product" → Show new product form │
│ - Click "Detail" → Show product detail │
│ │
└────────────────────────────────────────────┘
Without nested routing, you would need to write the title bar and navigation tabs repeatedly in each of the three page components. With nested routing, the common parts are written only once (in the parent component), and the changing parts are dynamically replaced by <Outlet />.
4.4.2 Complete Code Implementation
Step 1: Parent component defines the layout framework + <Outlet /> placeholder
// src/Products/index.jsx (16 lines total)
import { Outlet } from 'react-router-dom';
// Outlet means "outlet/socket"; the component matched by the child route will render here
const Products = () => {
return (
<>
<h2>Products List</h2>
<p>This is the product list content</p>
<Outlet />
{/* ↑↑↑ Key! The child route's content plugs in here like a plug */}
</>
);
};
export default Products;
Step 2: Parent Route wraps Child Routes
// App.jsx lines 43-47
<Route path="/products" element={<Products />}>
{/* These two child Routes are children of the Products component's Route */}
{/* Their paths are relative to /products */}
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
Step 3: Rendering effect comparison
URL = /products (when no child route matches):
┌──────────────────────────────┐
│ Products List │
│ This is the product list content │
│ <Outlet /> → Empty (renders nothing)│
└──────────────────────────────┘
URL = /products/new:
┌──────────────────────────────┐
│ Products List │
│ This is the product list content │
│ ┌──────────────────────────┐ │
│ │ NewProduct │ │ ← This is the content rendered by <Outlet />
│ │ This is new product content │ │
│ └──────────────────────────┘ │
└──────────────────────────────┘
URL = /products/42:
┌──────────────────────────────┐
│ Products List │
│ This is the product list content │
│ ┌──────────────────────────┐ │
│ │ Product Detail: 42 │ │ ← ProductDetail component renders here
│ └──────────────────────────┘ │
└──────────────────────────────┘
4.4.3 Matching Details of Nested Routing
URL = "/products/new"
Routes matching process:
├── path="/" → Path is "/products/new", no match
├── path="/about" → No match
├── path="/user/:id" → No match
├── path="/products" → ✔ Prefix match! Path starts with "/products"
│ ├── path=":productId" → "new" could match :productId (any string),
│ │ but path="new" is a static path with higher exact match priority
│ │ So this route is skipped
│ └── path="new" → ✔ Full match! Renders <NewProduct />
│
└── Render result: <Products><NewProduct /></Products>
Products first renders its own <h2> and <p>, then renders NewProduct at the <Outlet /> location
4.4.4 Multi-Level Nesting
Nested routing can go beyond two levels. For example, a more complex scenario:
<Route path="/dashboard" element={<Dashboard />}>
<Route path="settings" element={<Settings />}>
<Route path="profile" element={<ProfileSettings />} />
<Route path="security" element={<SecuritySettings />} />
</Route>
</Route>
URL: /dashboard/settings/profile
Render hierarchy:
<Dashboard>
<Settings>
<ProfileSettings /> ← Ultimately renders in the innermost Outlet
</Settings>
</Dashboard>
Each layer only needs to place one <Outlet /> in its own component, and React Router automatically handles the hierarchical relationship.
4.5 Route Redirection: The <Navigate> Component
4.5.1 Code and Syntax
// App.jsx lines 49-52
<Route path="/old-path" element={
<Navigate replace to="/products/new" />
} />
<Navigate> is a component-form instruction. When it is rendered, it immediately triggers a route jump. It is essentially equivalent to:
// Simplified internal implementation of Navigate
function Navigate({ to, replace }) {
const navigate = useNavigate(); // Get the routing navigate function
useEffect(() => {
navigate(to, { replace }); // Execute the jump
}, [to, replace]);
return null; // Renders no DOM itself
}
4.5.2 In-Depth Analysis of the replace Parameter
This is the most easily misunderstood parameter. It involves the concept of the browser's History Stack:
Scenario: User clicks a link from the Home page to /old-path, then is automatically redirected to /products/new
History stack without replace (push mode):
[0] Home
[1] /old-path ← This entry is pushed onto the history stack
[2] /products/new ← Another entry pushed after redirection
User clicks "Back" → Returns to /old-path → Triggers redirection again → Page flashes back to /products/new
User clicks "Back" again → Returns to Home
(User is trapped in a loop, can never go back to Home)
History stack with replace:
[0] Home
[1] /products/new ← The /old-path record is replaced
User clicks "Back" → Returns directly to Home ✔
Therefore, redirect routes should almost always use replace, otherwise it creates a trap for the browser's back button.
4.5.3 useNavigate Hook: Imperative Navigation
Besides the <Navigate> component (declarative), React Router also provides the useNavigate Hook (imperative):
import { useNavigate } from 'react-router-dom';
function LoginForm() {
const navigate = useNavigate();
const handleLogin = async () => {
const success = await loginAPI(username, password);
if (success) {
navigate('/dashboard', { replace: true }); // Navigate after successful login
}
};
return <button onClick={handleLogin}>Login</button>;
}
Declarative vs Imperative:
<Navigate>: Suitable for static redirects in route configuration (like old URL migration)useNavigate: Suitable for dynamic jumps in event handlers (like after login success, form submission)
4.5.4 Two NotFound Components in This Project
Note that there are two NotFound files in the project:
// src/pages/NotFound/index.jsx —— Currently used (with 3-second auto-redirect)
import { useEffect } from 'react';
const NotFound = () => {
useEffect(() => {
setTimeout(() => {
window.location.href = '/'; // Use native method to redirect to home after 3 seconds
}, 3000);
}, []);
return (
<>
<h2>404 Page Not Found</h2>
<p>The page you visited is lost</p>
</>
);
};
export default NotFound;
// src/NotFound/index.jsx —— Alternative version (pure display, no redirect)
const NotFound = () => {
return (
<div>
<h2>404 Not Found</h2>
<p>Page does not exist</p>
</div>
);
};
export default NotFound;
The difference between the two implementations: The pages/NotFound version uses window.location.href = '/' to auto-redirect to the home page after 3 seconds. It uses native window.location.href reassignment instead of useNavigate, which actually triggers a full page refresh (not a SPA no-refresh jump). If pursuing a pure SPA experience, it should be changed to:
// A more SPA-idiomatic way (for illustration only, the project uses the one above)
import { useNavigate } from 'react-router-dom';
const NotFound = () => {
const navigate = useNavigate();
useEffect(() => {
const timer = setTimeout(() => navigate('/', { replace: true }), 3000);
return () => clearTimeout(timer); // Clear timer on component unmount
}, [navigate]);
return (...);
};
4.6 404 Fallback: path="*" Wildcard Route
4.6.1 Matching Semantics of the Asterisk *
// App.jsx line 55
<Route path="*" element={<NotFound />} />
* means "match any path", including:
/random-page/user/123/profile/settings/anything/at/all- Even all possible paths after
/
* must be placed in the last position of <Routes>. Because Routes matching is "stop at the first matching Route". If * is placed in front, it will swallow all subsequent routes.
// ❌ Incorrect: * in front prevents /about, /user, etc. from ever being hit
<Routes>
<Route path="*" element={<NotFound />} /> // First, matches everything
<Route path="/" element={<Home />} /> // Will never be matched
<Route path="/about" element={<About />} /> // Will never be matched
</Routes>
// ✔ Correct: * at the end, only catches paths not matched by the preceding ones
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} /> // Last, fallback
</Routes>
4.6.2 Different Uses of *
path="*" → Matches all paths (for 404 fallback)
path="/user/*" → Matches all paths starting with /user/ (like /user/123/profile)
path="*-preview" → Matches paths ending with -preview
4.7 React.lazy() + Suspense: Route-Level Code Splitting
4.7.1 Quantitative Analysis of the Problem
Assume a medium-sized SPA has 10 pages, with a total JS size of 500KB:
Without code splitting (static import):
bundle.js = 500KB (all pages bundled together)
First load = Download + Parse + Execute 500KB
FCP (First Contentful Paint) = Slow
With code splitting (lazy import):
main.js = 200KB (Framework + common logic)
Home.chunk.js = 50KB
About.chunk.js = 40KB
User.chunk.js = 45KB
Products.chunk.js = 55KB
...6 other pages...
First load (visiting Home page) = 200KB + 50KB = 250KB
FCP = Twice as fast
4.7.2 Code Implementation (Line-by-Line Analysis)
// App.jsx lines 1-4
import { lazy, Suspense } from 'react';
// lazy: Receives a function returning Promise<{default: Component}>
// Suspense: Displays fallback content while the lazy component is loading
// App.jsx lines 7-11
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
// These are static imports—framework-level libraries needed by every page, so no lazy loading
// App.jsx line 12
import Navigation from './component/Navigation';
// Navigation is the global nav bar, displayed on every page, static import
// App.jsx lines 13-17 (commented out code)
// import Home from './pages/Home';
// import About from './pages/About';
// import User from './pages/User';
// Above are the replaced static imports, below are the lazy loaded versions ↓
// App.jsx lines 19-25
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const User = lazy(() => import('./pages/User'));
const NotFound = lazy(() => import('./pages/NotFound'));
const Products = lazy(() => import('./Products'));
const ProductDetail = lazy(() => import('./Products/ProductDetail'));
const NewProduct = lazy(() => import('./Products/New'));
4.7.3 How lazy + import() Works
// Normal import (static import) — determined at compile time, always bundled together
import Home from './pages/Home';
// Dynamic import() — loaded on demand at runtime, Vite automatically splits the chunk
const Home = lazy(() => import('./pages/Home'));
// ^^^^^^^^^^^^^^^^^^^^^^^^
// This is a function returning Promise<Module>
// Vite/Rollup sees dynamic import() and automatically splits it into an independent chunk
Execution sequence diagram:
1. User first visits http://localhost:5173/#/
│
2. Browser downloads index.html + main.js (contains App.jsx route configuration code)
│
3. React renders <App />
│
4. HashRouter initializes, current pathname = "/"
│
5. <Routes> matches path="/" → finds element={<Home />}, but Home is lazy
│
6. React triggers loading of the lazy component:
import('./pages/Home') → Browser initiates network request → Downloads Home-abc123.js
│
7. During download: <Suspense fallback={<div>Wait for me...</div>}>
User sees: "Wait for me..."
│
8. Download complete → Promise resolves → React replaces fallback with real <Home />
User sees: Home page content
│
9. User clicks navigation "About"
│
10. location.hash = "#/about" → hashchange → Routes re-matches
│
11. Matches path="/about" → element={<About />} (also lazy)
→ import('./pages/About') → Browser requests About-def456.js
│
12. During download, "Wait for me..." is displayed again
(Home.chunk.js is still in browser cache, no need to re-download)
│
13. About download complete → Renders <About />
4.7.4 Suspense's fallback and User Experience
// App.jsx line 32
<Suspense fallback={<div>Wait for me...</div>}>
fallback can be any React element. In production environments, it's usually a Loading animation component:
// Common practice in production:
<Suspense fallback={<PageLoading />}>
<Routes>
{/* ... */}
</Routes>
</Suspense>
// PageLoading component might contain:
// - Skeleton screen mimicking page layout
// - Top progress bar (NProgress style)
// - Centered Spin animation
Considerations on Suspense boundary placement:
This project only sets one top-level Suspense, with all page loads sharing one fallback. If you want different loading effects for different pages, or to prevent one page's loading from blocking already loaded areas, you can set multiple Suspenses:
// Fine-grained control: Independent Suspense for each Route (example, not project code)
<HashRouter>
<Navigation /> {/* Navigation not wrapped in Suspense, always displayed */}
<Routes>
<Route path="/" element={
<Suspense fallback={<HomeSkeleton />}>
<Home />
</Suspense>
} />
<Route path="/about" element={
<Suspense fallback={<AboutSkeleton />}>
<About />
</Suspense>
} />
</Routes>
</HashRouter>
4.8 Link Component: Complete Anatomy of Declarative Navigation
4.8.1 Source Code
// src/component/Navigation.jsx (20 lines total)
// a click triggers a jump, secondary processing
// Don't use a directly, react-router-dom provides a reliable link component
// Suitable for SPA
import { Link } from 'react-router-dom';
function Navigation() {
return (
<nav>
<ul>
<li><Link to="/">Home</Link></li>
<li><Link to="/about">About</Link></li>
<li><Link to="/user/123">Xiao Jia</Link></li>
<li><Link to="/products/123">Product Detail</Link></li>
<li><Link to="/products/new">New Product</Link></li>
</ul>
</nav>
);
}
export default Navigation;
4.8.2 Fundamental Difference Between <a> and <Link>
User clicks <a href="/about">About</a>:
1. Browser default behavior: Initiates GET /about request
2. Server returns HTML
3. Browser discards current DOM
4. Parses new HTML
5. Rebuilds CSSOM
6. Re-executes JS
7. Renders new page
→ Result: White screen + All React state lost + SPA experience destroyed
User clicks <Link to="/about">About</Link>:
1. Link intercepts the click event (event.preventDefault())
2. Calls location.hash = '#/about' (in this project's HashRouter mode)
3. Browser triggers hashchange event (no page refresh)
4. HashRouter updates internal state
5. React schedules a re-render
6. Routes re-matches, renders <About />
7. React only updates the changed parts of the DOM
→ Result: No white screen + All state preserved + Pure SPA experience
4.8.3 What Does Link Render to in the DOM?
<!-- <Link to="/about">About</Link> renders to the DOM as: -->
<a href="#/about">About</a>
<!-- It is still an <a> tag! -->
<!-- The href is automatically prefixed with # (because HashRouter is used) -->
<!-- So right-click "Open in new tab", hovering to see URL, screen readers, etc., all work normally -->
This is the design wisdom of react-router-dom: Although it intercepts the click behavior, it still renders a standard <a> tag underneath, ensuring accessibility (a11y) and native browser features (like "Open in new tab").
4.8.4 Other Properties of Link
// replace: Replace the history entry instead of appending
<Link to="/about" replace>About</Link>
// state: Pass extra route state (not shown in the URL)
<Link to="/user/123" state={{ from: 'home', referrer: 'nav' }}>User</Link>
// The target component can read it via useLocation().state: { from: 'home', referrer: 'nav' }
// reloadDocument: Force native <a> behavior (rarely used)
<Link to="/about" reloadDocument>About</Link> // Equivalent to <a href="/about">
4.8.5 NavLink: Link with Active Styling
Not used in the project but worth knowing:
import { NavLink } from 'react-router-dom';
<NavLink
to="/about"
className={({ isActive }) => isActive ? 'active' : ''}
// Or use style
style={({ isActive }) => ({ fontWeight: isActive ? 'bold' : 'normal' })}
>
About
</NavLink>
NavLink builds upon Link by automatically detecting if the current URL matches its to path, making it easy to implement menu highlighting.
V. Detailed Project Engineering Configuration
5.1 Entry HTML: The Empty Shell of a Single Page Application
<!-- index.html (14 lines) -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-router</title>
</head>
<body>
<div id="root"></div>
<!-- ↑ This line is key! The entire React application mounts on this empty div -->
<!-- Vite processes this <script> during build, injecting the bundled JS -->
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
The core characteristic of a SPA is fully embodied here: The HTML file itself has almost no content, only an empty <div id="root">. All interfaces are dynamically generated by JavaScript and inserted into this div.
5.2 Entry JS: React Mounting
// src/main.jsx (10 lines total)
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
React 18+'s createRoot API (replaces React 17's ReactDOM.render):
createRootcreates a React rendering root node, bound to the DOM's#rootelement<StrictMode>is a development helper component that intentionally double-invokes certain functions (like reducers, effects, component function bodies) to help developers find side-effect related issues<App />is rendered as the root component of the entire application
5.3 Vite Configuration
// vite.config.js (7 lines total)
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
@vitejs/plugin-react uses the Oxc compiler (instead of traditional Babel or SWC). Oxc is written in Rust, faster than SWC, and specifically optimized for Vite. This plugin is responsible for:
- Compiling JSX into
React.createElementcalls (or React 19's automatic JSX transform) - Handling React Fast Refresh (HMR hot update)
- Extra checks and optimizations for the development environment
5.4 Dependency Version Interpretation
{
"dependencies": {
"react": "^19.2.7", // React 19: Supports Suspense, concurrent rendering,
// use() Hook, server components, etc.
"react-dom": "^19.2.7", // Strictly corresponds with react version
"react-router-dom": "7.18.2" // React Router v7: Latest stable version
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.3", // Vite's React plugin (Oxc)
"vite": "^8.1.1", // Vite 8: Next-generation front-end build tool
"eslint": "^10.6.0", // ESLint 10 (flat config)
"eslint-plugin-react-hooks": "^7.1.1", // Hooks rule checking
"eslint-plugin-react-refresh": "^0.5.3" // Fast Refresh related checks
}
}
React 19 new features related to routing:
- Suspense functionality further improved, better lazy loading experience
- Native support for
use()Hook, can read Promises during rendering (replaces some useEffect + useState patterns) - More efficient automatic JSX transform
VI. Complete Data Flow Tracing: The Entire Process of a Single Route Switch
Taking the example of a user clicking "About" in the navigation bar from the Home page, trace what happens at every link:
6.1 Timeline
T=0ms User sees the Home page in the browser, URL = http://localhost:5173/#/
DOM state: Navigation component + Home component rendered
React state: All loaded component states intact
T=1ms User clicks <Link to="/about">About</Link>
↓
T=2ms Inside the Link component:
const handleClick = (event) => {
event.preventDefault(); // ← Prevents the browser's default navigation behavior
navigate(to); // ← Calls react-router's navigate
};
↓
T=3ms The navigate function inside HashRouter executes:
window.location.hash = '#/about';
// Modifying the hash does not trigger a page refresh!
↓
T=4ms Browser detects the hash change, triggers the hashchange event
↓
T=5ms The handleHashChange registered by HashRouter executes:
const newPath = window.location.hash.slice(1); // "/about"
setLocation({ pathname: newPath }); // Updates React state
↓
T=6ms React detects HashRouter's state change, schedules a re-render
↓
T=7ms React enters the rendering phase:
- Navigation component: props unchanged, skips re-render (React.memo or VDOM comparison)
- HashRouter: Provides new location context
- Routes: Receives new pathname="/about"
↓
T=8ms Routes starts matching:
Iterates through all child <Route>:
path="/" → "/" !== "/about", no match
path="/about" → "/about" === "/about", ✔ Match!
Stops iteration (first match found)
↓
T=9ms React prepares to render <About />:
But About is a lazy component: lazy(() => import('./pages/About'))
Check: Is About's chunk already loaded?
If first visit → Not loaded → Triggers dynamic import()
If visited before → Already in browser cache → Use directly
↓
T=10ms Scenario A (First load):
Triggers import('./pages/About')
→ Browser initiates network request GET /About-def456.js
→ React finds lazy component status is "pending"
→ Looks upward for the nearest <Suspense> boundary
→ Displays fallback: <div>Wait for me...</div>
→ User sees a brief loading prompt
... Assuming 50ms network latency ...
T=60ms About-def456.js download complete
→ import() Promise resolves
→ React changes lazy component status to "resolved"
→ Component function executes, returns JSX
→ React replaces fallback with real DOM
→ User sees: "About" title
Scenario B (Cached):
Promise already resolved → Skip fallback → Render <About /> directly
↓
T=70ms Rendering complete. The #container area in the DOM changes from Home content to About content.
URL = http://localhost:5173/#/about
During the entire process:
- Browser did not refresh
- Navigation bar remained stably displayed
- React internal state (if any) preserved intact
- CSS, JS runtime environment not reset
6.2 More Complex Scenario: Nested Routing with Parameters
Process of navigating from / to /products/42:
1. Click <Link to="/products/123"> → hash = '#/products/123'
2. Routes matching:
├── "/" → No match
├── "/about" → No match
├── "/user/:id" → No match (/products ≠ /user)
├── "/products" → ✔ Prefix match (/products/123 starts with /products)
│ ├── ":productId" → ✔ Matches "123"
│ └── "new" → No match (123 ≠ new)
├── Render: <Products>
│ <h2>Products List</h2>
│ <p>This is the product list content</p>
│ <Outlet>
│ <ProductDetail>
│ <h3>Product Detail</h3>
│ <p>Product ID: 123</p>
│ </ProductDetail>
│ </Outlet>
│ </Products>
3. Inside ProductDetail:
const { productId } = useParams();
// productId = "123" (extracted by the path matcher inside Routes)
VII. Common Questions and Best Practices
7.1 Why doesn't the to property of <Link> have a # prefix?
<Link to="/about">About</Link>
// ^^^^^^^ No need to write "#/about"
Because Link and useNavigate use the internal routing path (pathname), not the full URL. HashRouter automatically handles the bidirectional conversion between the internal path and the URL hash:
Internal path "/about" ←→ HashRouter ←→ URL "#/about"
If you wrote <Link to="#/about">, HashRouter would parse it as pathname /#/about, which wouldn't match any route.
7.2 Child route paths in nested routing don't need the parent path prefix
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
{/* ^^^^^^^^^^^ Correct: relative path */}
{/* ❌ path="/products/:productId" Incorrect: absolute path */}
</Route>
A child route's path property automatically inherits the parent route's path prefix. Writing an absolute path might also work (in some versions), but it breaks the portability of the route configuration—if you later change the parent route's path, all child routes would need to be changed accordingly.
7.3 Only one <Route> matches inside <Routes>
This is the core rule of React Router v6+: No matter how many <Route>s there are, <Routes> ultimately renders only the first one that matches. This is an important change from v5 (v5 used <Switch>, similar behavior but different API).
This means you don't need to manually consider route priority ordering (except * must be last); React Router matches in the order you write them.
7.4 Handling trailing slashes in paths
path="/about" → Matches "/about" → Does not match "/about/"
path="/about/" → Matches "/about/" → Does not match "/about"
By default, React Router v6 is sensitive to trailing slashes. If you need to be compatible with both, you can use optional parameters or configuration.
7.5 Webpack/Vite Magic Comments for Dynamic Import
// Vite automatically splits chunks, but chunk names can be controlled via file names
const Home = lazy(() => import('./pages/Home'));
// → Generates Home-[hash].js
// In webpack, magic comments can control chunk names (Vite defaults to readable names based on file paths)
const Home = lazy(() => import(/* webpackChunkName: "page-home" */ './pages/Home'));
Vite (based on Rollup) automatically generates readable chunk names based on file paths, requiring no extra comments.
VIII. Summary Quick Reference (Return to the Tip of the Pyramid)
Returning to the core conclusion at the beginning: React Router uses HashRouter to listen for URL changes, uses declarative <Routes>/<Route> configuration for mapping, cooperates with <Link> for refresh-free navigation and React.lazy() for on-demand loading, transforming the traditional back-end routing pattern of "URL → server-side render new page" into a "URL → client-side match component → partial DOM update" pattern, achieving a SPA experience with no white screen, state preservation, and on-demand loading.
Quick Reference for the Eight Core Features of This Project
| No. | Feature | Involved Files | Key API | Core Point |
|---|---|---|---|---|
| 1 | Hash Routing | App.jsx:7 | HashRouter |
Content after # not sent to server, hashchange event driven, zero server config |
| 2 | Route Config Table | App.jsx:37-56 | Routes, Route |
Declarative JSX config, first match renders, * must be last |
| 3 | Dynamic Params | User.jsx, ProductDetail.jsx | useParams, :id |
Colon defines dynamic segment, path matcher extracts params, RESTful style URL |
| 4 | Nested Routing | Products/index.jsx | Outlet |
Parent component provides layout framework + Outlet placeholder, child route paths written relatively |
| 5 | Redirection | App.jsx:50-52 | Navigate |
Component-form redirect instruction, replace prevents back button dead loop |
| 6 | 404 Fallback | App.jsx:55, NotFound/index.jsx | path="*" |
Wildcard greedy match, 3-second auto-redirect to home page |
| 7 | Lazy Loading | App.jsx:19-25 | React.lazy, Suspense |
Dynamic import() chunk splitting, Suspense fallback displays loading state |
| 8 | Declarative Nav | Navigation.jsx | Link |
Intercepts <a> default behavior, SPA no-refresh jump, renders as real <a> tag |
Key File and Line Number Index
| File | Key Lines | Content |
|---|---|---|
App.jsx |
7 | HashRouter as Router import |
App.jsx |
19-25 | 7 lazy() lazy loading declarations |
App.jsx |
31 | <Router> root container |
App.jsx |
32 | <Suspense fallback> loading boundary |
App.jsx |
37-56 | <Routes> complete route configuration table |
App.jsx |
38 | path="/" root route |
App.jsx |
40 | path="/user/:id" dynamic route |
App.jsx |
43-47 | Nested routing (Products + child routes) |
App.jsx |
50-52 | <Navigate> redirect |
App.jsx |
55 | path="*" 404 fallback |
Navigation.jsx |
4,10-15 | Link component usage |
User.jsx |
3,6 | useParams usage |
Products/index.jsx |
2,10 | Outlet usage |
Products/ProductDetail.jsx |
1,4 | useParams in nested routing |
NotFound/index.jsx |
6-9 | 3-second auto-redirect to home page |
main.jsx |
6 | createRoot mount |
index.html |
11 | <div id="root"> mount point |
vite.config.js |
5 | @vitejs/plugin-react config |
package.json |
13-15 | Core dependency versions |