跪拜 Guibai
← Back to the summary

React Router v7 From Scratch: 7 Patterns Every SPA Needs

React Router v7 Complete Guide: Master Frontend Routing with One Demo

Author: 烬羽 Tags: React.js, Frontend Framework, JavaScript

Not a list of APIs, but building a complete React single-page application routing system from scratch. After reading, you will know why each API exists, when to use it, and the pitfalls to avoid.


Table of Contents


Opening: Are You Really Opening a "New Page"?

You see /home, /products/123, /about in the browser address bar, looking exactly like a traditional website. But have you noticed—there is no white screen during page transitions, no loading spinner, and even the Network panel in DevTools shows no new HTML requests.

This isn't magic, this is frontend routing.

This article takes you through building a complete frontend routing system from scratch using React Router v7, covering 7 core knowledge points. Each point answers three questions: what it is, why it's needed, and how to write it (and why to write it that way).


1. Why Frontend Routing Exists

Let's go back to the essence: What is routing?

Routing is a mapping relationship—"this URL → that content." When the path in the browser address bar changes, the content displayed on the page changes.

The traditional approach works like this:

sequenceDiagram
    participant U as User
    participant B as Browser
    participant S as Server
    U->>B: Clicks /about link
    B->>S: GET /about.html
    S-->>B: 200 OK Entire HTML document
    B->>B: Clears page, re-renders entire DOM
    Note over B: White screen flash

Every navigation is an HTTP round trip + full page rebuild. On a 3G network or with a heavy page, the user experience is "click, flash white, wait a bit."

The frontend routing approach is different:

graph LR
    A[URL Changes] --> B{How to handle}
    B -->|Traditional| C[Server returns new HTML]
    C --> D[Full page refresh]
    B -->|Frontend Routing| E[JavaScript intercepts]
    E --> F[Partial DOM replacement]
    F --> G[Smooth transition, no white screen]

Core concept: The URL must change (different pages need different URLs), but you don't ask the server for new HTML every time—JavaScript swaps the DOM on the client side itself.

There are two implementation methods: Hash routing (URL contains #) and History routing (clean URLs like /page1). This project uses HashRouter, whose principle is listening for the hashchange event—changes to the hash part do not trigger the browser to send an HTTP request, so the page does not refresh.


2. Your First Route: Routes, Route, Link

Let's look at the most basic route configuration in the project:

// App.jsx — Route configuration, the "master switch" for all routes
import { HashRouter, Routes, Route } from 'react-router-dom'

<HashRouter>            {/* ① Router: wraps everything, decides Hash or History mode */}
  <Routes>              {/* ② Route collection: holds multiple Routes, renders only the first match */}
    <Route path="/" element={<Home />} />
    <Route path="/about" element={<About />} />
    <Route path="/user/:id" element={<UserProfile />} />
  </Routes>
</HashRouter>

Three roles, clear division of labor:

Component Responsibility Analogy
HashRouter Listens for URL changes, provides routing context The entire "navigation system"
Routes Scans all child Routes, finds the matching one to render "Navigation map"
Route One rule: this path corresponds to that element "A road"

Navigation Links: Why Not Use <a> Tags?

// ❌ Using a tag: clicking triggers a full page refresh, all SPA state is lost
<a href="/about">About Us</a>

// ✅ Using Link component: React Router intercepts the click, only does a partial replacement
// components/Navigation.jsx
import { Link } from 'react-router-dom'

<Link to="/">Home</Link>
<Link to="/about">About</Link>

<Link> essentially still renders an <a> tag, but it intercepts the click event, prevents the browser's default page jump behavior, and instead uses JavaScript to update the URL and trigger a component switch.

⚠️ Newbie pitfall: Using <a> for SPA internal navigation. The URL changes, but the page does a full refresh, and all previous state (form inputs, scroll position, Redux store) is lost. Except for navigating to external websites, always use <Link> inside an SPA.


3. Route Lazy Loading: The Secret to a Homepage That's Three Seconds Faster

If a project has 20 pages, a user opening the homepage would have to download the JS code for all 20 pages—but they might only look at the homepage.

// App.jsx
import { lazy, Suspense } from 'react'

// 🔑 webpack/vite sees import() and splits each page into an independent JS chunk
const Home = lazy(() => import('./pages/Home'))
const About = lazy(() => import('./pages/About'))
const UserProfile = lazy(() => import('./pages/UserProfile'))

// ⚠️ Lazy-loaded components must be wrapped with Suspense,
// because loading takes time, React needs to know "what to show while it's not loaded yet"
<Suspense fallback={<div>Loading...</div>}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/about" element={<About />} />
    {/* ... */}
  </Routes>
</Suspense>

lazy() tells the bundler: "Package this component separately, download it only when accessed." Suspense's fallback is the placeholder UI during the download.

The loading timing is clear at a glance:

graph TD
    A["User visits /"] --> B["Download Home chunk"]
    A -.-> C["Do not download About chunk"]
    A -.-> D["Do not download UserProfile chunk"]
    E["User clicks About"] --> F["Download About chunk"]

Effect: The homepage goes from "download all 20 pages" to "download only the current 1 page," reducing the initial screen load by over 80%.


4. Nested Routes: The Structural Beauty of Pages Within Pages

In real projects, pages are rarely "flat." For example, a product module has two sub-pages—Product Detail and New Product, both hanging under /products:

// App.jsx — Nested routes: child Routes are written inside the parent Route tag
<Route path="/products" element={<Products />}>
  <Route path=":productId" element={<ProductDetail />} />
  <Route path="new" element={<NewProduct />} />
</Route>

Note that the child route's path is a relative path (":productId" not "/products/:productId"). React Router automatically concatenates it to /products/:productId.

The parent component Products needs a "slot" to place the child route's content:

// pages/products/index.jsx
import { Outlet } from 'react-router-dom'

const Products = () => <Outlet />
// 🔑 Outlet is the "render slot for child routes": when visiting /products/123, Detail renders here
// when visiting /products/new, NewProduct renders here

The route matching process is as follows:

graph TD
    A["URL: /products/123"] --> B["Matches Route /products"]
    B --> C["Renders Products component"]
    C --> D["Outlet inside Products waits for child route"]
    D --> E["Continues matching child route :productId"]
    E --> F["Renders ProductDetail into the Outlet position"]
    F --> G["Final output: h1 Product List + h3 Product Detail-123"]

Nested routes in one sentence: The parent Route is responsible for the "frame" (sidebar, breadcrumbs, module title), and Outlet is the "content area" reserved for child pages. Each level deeper in the URL renders one more layer inside the Outlet.


5. Dynamic Route Parameters: One Component Renders Different Data

/user/123 and /user/456 both use the UserProfile component, but display different users. useParams is responsible for extracting the dynamic part from the URL:

// pages/UserProfile/index.jsx
import { useParams } from 'react-router-dom'

const UserProfile = () => {
  const { id } = useParams()  // 🔑 Destructures the value of :id from the URL
  console.log(id)             // Visiting /user/123 → "123"
  return <p>User ID: {id}</p>
}

The colon prefix in :id in the route configuration means "this is a dynamic segment," and React Router extracts it and puts it into the object returned by useParams(). The same applies with nested routes—the productId matched by /products/:productId is available via useParams() in ProductDetail and any of its deeper child components.

Real-world scenarios usually pair this with useEffect to make a request:

const { productId } = useParams()
useEffect(() => {
  fetch(`/api/products/${productId}`).then(/* ... */)
}, [productId])  // ⚠️ Don't forget the dependency: re-request when productId changes

6. Programmatic Navigation: Jumping Without Link

Link is for "user actively clicks," but some scenarios require "code automatically jumps"—like redirecting to a list page after form submission, redirecting to a login page after session expiry, or redirecting back to the homepage after a countdown on a 404 page.

useNavigate is for exactly this:

// pages/NotFound/index.jsx — Automatically jump back to homepage after 3 seconds
import { useNavigate } from 'react-router-dom'

const navigate = useNavigate()
useEffect(() => {
  setTimeout(() => navigate('/'), 3000)
}, [])

navigate() works exactly the same way as clicking <Link to="/">—both go through React Router's interception and do not trigger a full page refresh. Using window.location.href = '/' can also jump, but that is a "real page jump"—full page refresh, all state lost.

Use cases for the three:

Method Scenario Refreshes Page?
<Link to="/"> Navigation bars, menus ❌ No refresh
navigate('/') After form submission, redirect to login when lacking permissions ❌ No refresh
window.location.href Navigating to an external website (the only recommended scenario) ✅ Refreshes

7. 404 Fallback: An Elegant "Page Not Found"

A user enters a non-existent URL; you can't show them a white screen or an error. The path="*" wildcard matches all paths not caught by the Routes above:

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/about" element={<About />} />
  {/* ... other routes ... */}

  {/* 🔑 Put this last! Matches all URLs not matched above */}
  <Route path="*" element={<NotFound />} />
</Routes>

⚠️ Key pitfall: * must be placed at the very end of <Routes>. React Router matches top to bottom, and * can match any path. If placed at the front, none of the subsequent Routes will ever be matched—users will never see Home or About, only 404.


8. Layout Decisions: What Goes in the Parent Route, What Goes in the Child Route

This is the most common design problem encountered in actual development. Reviewing our project structure:

/products              → Products component (layout layer)
  /products/:productId → ProductDetail (content layer)
  /products/new        → NewProduct (content layer)

What goes in the parent route? Things commonly needed by all child pages. For example, sidebars, breadcrumbs, module navigation.

What goes in the child route? Things needed only by a specific page. For example, the "Product List" title—only the detail page needs it, the new product page does not.

// ✅ Correct: Parent route only does the "frame", child routes are each responsible for their own content
// Products — Pure container, has no UI of its own
const Products = () => <Outlet />

// Detail — Needs the "Product List" title itself, adds it itself
const ProductDetail = () => {
  const { productId } = useParams()
  return (
    <>
      <h1>Product List</h1>
      <h3>Product Detail-{productId}</h3>
    </>
  )
}

// New — Doesn't need the title, doesn't care about anything
const NewProduct = () => <h1>New Product</h1>

The judgment criterion in one sentence: After adding a piece of UI to the parent route, ask yourself—"Does this UI appearing on /products/new make sense?" If it doesn't, move it down.

If in the future all 3 sub-pages of the product module need the same sidebar, then lift the sidebar back up into Products—this is the same idea as lifting state in React, except you're lifting UI instead of state.


Conclusion: A Diagram Reviewing All Knowledge Points

graph TD
    A["HashRouter wraps everything"] --> B["Routes route collection"]
    B --> C1["Route / → Home"]
    B --> C2["Route /about → About"]
    B --> C3["Route /user/:id → UserProfile"]
    B --> C4["Route /products → Products"]
    B --> C5["Route * → NotFound"]

    C4 --> D1["Outlet"]
    D1 --> E1["Child Route :productId → Detail"]
    D1 --> E2["Child Route new → NewProduct"]

    C1 -.-> F1["lazy + Suspense lazy loading"]
    C2 -.-> F1
    C3 -.-> F2["useParams gets dynamic parameters"]
    C5 -.-> F3["useNavigate jumps back after 3 seconds"]

This Demo contains 7 core knowledge points of React Router:

# Knowledge Point File
1 Basic route configuration (HashRouter / Routes / Route) App.jsx
2 Link navigation (don't use a tags) Navigation.jsx
3 Route lazy loading (lazy + Suspense) App.jsx
4 Nested routes (Outlet) App.jsx + products/index.jsx
5 Dynamic parameters (useParams) UserProfile/index.jsx + Detail/index.jsx
6 Programmatic navigation (useNavigate) NotFound/index.jsx
7 404 fallback (path="*") App.jsx

The essence of frontend routing is using JavaScript to take over the "URL → content" mapping that originally belonged to the server. React Router does three things on top of this: declarative configuration, componentized routing, and automatic performance optimization. Understanding these seven knowledge points gives you the full picture of React frontend routing.

Next time you set up routing for a new project, open this Demo and follow this structure—lazy loading for speed, nested routes for layering, 404 fallback, not a single one missing.