跪拜 Guibai
← Back to the summary

React Router v7's Most Common Pitfalls, Fixed in One Demo

Vite + React 19 + Router v7: Understanding HashRouter, Nested Routes, Lazy Loading, and 404 Fallback in One Go

Build an SPA routing system from scratch, covering HashRouter, nested routes, route lazy loading, parameter passing, redirects, and 404 fallbacks — each knowledge point comes with a "pitfall warning."

Preface

If you are learning React routing, you will likely encounter these problems:

This article uses a complete demo project to break down the core APIs of React Router v7 one by one. It is suitable for students who have just started learning React routing and want to systematically organize their knowledge. A complete, runnable project code is attached at the end of the article; clone and use it immediately.

After reading, you will gain:

Project Overview

This is a Hash mode single-page application based on Vite + React 19 + react-router-dom v7, containing 6 pages and a navigation bar.

Component Tree

App
├── HashRouter
│   └── Suspense (Loading... fallback)
│       ├── Navigation          ← Navigation bar, Link navigation
│       └── Routes
│           ├── "/"             → Home
│           ├── "/about"        → About
│           ├── "/user/:id"     → UserProfile      (dynamic parameter)
│           ├── "/products"     → Products          (parent route)
│           │   ├── ":productsId" → ProductDetail   (child route)
│           │   └── "new"        → NewProduct       (child route)
│           ├── "/old-path"     → Navigate redirect to /new-path
│           └── "*"             → NotFound          (404 fallback)

Core Technical Points

Category APIs Used
Routing Mode HashRouter
Route Configuration Routes, Route
Navigation Link
Dynamic Parameters useParams
Nested Routes Outlet
Lazy Loading React.lazy + Suspense
Redirect Navigate
Programmatic Navigation useNavigate

Core Knowledge Points: Deep Dive into the 3 Most Important Concepts

Knowledge Point 1: HashRouter — Why is there a # in the URL?

What is it?

HashRouter uses the part after the # in the URL (called the hash) to manage front-end routing. When the content after the # changes, the browser does not send a request to the server, and the page does not refresh.

https://example.com/#/user/123
                       ↑
                     This whole segment is called the hash

Why does this project use HashRouter instead of BrowserRouter?

HashRouter BrowserRouter
URL Appearance xxx.com/#/about xxx.com/about
Page Refresh No 404 Requires server configuration (otherwise 404)
Deployment Difficulty Zero config, just drop it on a server Requires nginx/Caddy fallback configuration
Applicable Scenarios Demo projects, static deployment, GitHub Pages Production applications (with backend support)

In one sentence: HashRouter is hassle-free, BrowserRouter looks clean. Use HashRouter for demo projects and static hosting; use BrowserRouter for official launches with backend support.

How is it used in this project?

// App.jsx
import { HashRouter as Router } from 'react-router-dom';

<Router>
  <Navigation />
  <Routes>
    <Route path="/" element={<Home />} />
    {/* ... */}
  </Routes>
</Router>

HashRouter acts like a "shell," wrapping the entire application and taking over all routing logic.


Knowledge Point 2: Nested Routes + Outlet — Parent component stays the same, child components switch

What is it?

Nested routes allow you to write the common parts of a page in the parent component, while the child routes are only responsible for the changing content.

Use a real-life analogy:

On the Taobao product listing page, the top navigation bar and sidebar filter bar are always there. When switching between "Mobile Phones" or "Computers" categories, only the product area in the middle changes. Parent component = shell (navigation + filter bar), child component = that block of product cards in the middle.

How is it used in this project?

Route configuration (App.jsx):

<Route path="/products" element={<Products />} >       {/* Parent: shell */}
  <Route path=":productsId" element={<ProductDetail />} />  {/* Child: detail */}
  <Route path="new" element={<NewProduct />} />             {/* Child: new */}
</Route>

Parent component (Products/index.jsx):

import { Outlet } from 'react-router-dom';

const Products = () => (
  <>
    <h1>Product List</h1>    {/* Common title, always displayed */}
    <Outlet />           {/* Child component renders here */}
  </>
);

Actual effect:

Visited URL <h1> Product List </h1> Rendered in <Outlet />
/products ✅ Displayed Empty
/products/123 ✅ Displayed <ProductDetail />
/products/new ✅ Displayed <NewProduct />

Pitfall warning: <Outlet /> must be written inside the parent component's return, not in the route configuration! Many people configure <Outlet /> as a property of <Route>, and it never works. This is the reason.


Knowledge Point 3: React.lazy + Suspense — Load on demand, no lag on the homepage

What is it?

By default, import Home from './pages/Home' bundles the Home component's code into the main file — no matter which page the user visits, they have to download the code for all pages.

React.lazy() combined with import() implements dynamic imports: the JS for a page is only downloaded when the user actually visits that page.

How is it used in this project?

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

// ❌ Static import: all pages downloaded together
// import Home from './pages/Home';

// ✅ Dynamic import: downloaded only when visited
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
const NotFound = lazy(() => import('./pages/NotFound'));
const Products = lazy(() => import('./pages/Products'));
const ProductDetail = lazy(() => import('./pages/Products/ProductDetail'));
const NewProduct = lazy(() => import('./pages/Products/NewProduct'));

The role of Suspense

When a lazy component is downloading, React needs to display a "loading" interface — this is the fallback of <Suspense>:

<Suspense fallback={<div>Loading...</div>}>
  <Navigation />
  <Routes>
    <Route path="/" element={<Home />} />
    {/* ... */}
  </Routes>
</Suspense>

When a user visits /about for the first time, the process is:

Click the "About" link
  ↓
React finds the About component hasn't been downloaded yet
  ↓
Display fallback: <div>Loading...</div>   ← User sees loading prompt
  ↓
Asynchronously download About's JS file
  ↓
Download complete, replace with <About /> component        ← User sees page content

Pitfall warning: <Suspense> must wrap the lazy component, otherwise React will throw an A React component suspended while rendering error.


Component-by-Component Breakdown

Navigation — Navigation Bar

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">Product List</Link></li>
        <li><Link to="/products/new">New Product</Link></li>
        <li><Link to="/products/123">Product Detail</Link></li>
      </ul>
    </nav>
  );
}

Why use <Link> instead of <a>?

<a href="/about"> <Link to="/about">
Page Refresh Entire page refreshes, white flash No refresh, partial replacement
SPA Experience ❌ Loses SPA advantages ✅ Smooth switching
State Preservation ❌ Lost ✅ Preserved

<Link> calls history.pushState() under the hood, changing the URL without triggering a browser refresh — this is the cornerstone of the SPA experience.


UserProfile — Dynamic Route Parameters

import { useParams } from 'react-router-dom';

function UserProfile() {
    const { id } = useParams();   // Extract parameter from URL
    return <h1>UserProfile {id}</h1>;
}

The chain:

Route rule  /user/:id           ← :id is a placeholder, defines the parameter name
User visits  /user/Xiao Jia           ← "Xiao Jia" fills the :id position
useParams() returns  { id: "Xiao Jia" }  ← Component gets the parameter value
Page displays  UserProfile Xiao Jia

Products + ProductDetail — Nested Routes + Parameter Name Matching Issue ⚠️

Route configuration:

<Route path="/products" element={<Products />} >
  <Route path=":productsId" element={<ProductDetail />} />
  <Route path="new" element={<NewProduct />} />
</Route>

Note there are two child routes here:

ProductDetail component:

function ProductDetail() {
    const { productId } = useParams();  // Read productId from URL
    return <h3>Product Detail {productId}</h3>;
}

🔴 Here is an extremely easy-to-overlook pitfall!

The route defines :productsId (with an 's'), but the component destructures productId (without an 's'). The parameter name must be exactly the same, otherwise productId will always be undefined.

useParams() returns an object whose key is the name after the : in the route. path=":productsId" means useParams() returns { productsId: '123' }, so trying to get productId naturally fails.

Correct way: const { productsId } = useParams();


NotFound — 404 Fallback + Programmatic Navigation

import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

const NotFound = () => {
    const navigate = useNavigate();

    useEffect(() => {
        setTimeout(() => {
            navigate('/');     // Automatically jump back to homepage after 3 seconds
        }, 3000);
    }, []);

    return <h1>404 Not Found</h1>;
};

Two key points:

  1. path="*" fallback matching — Placed at the end of <Routes>, all unmatched paths will be caught by it:

    <Route path="*" element={<NotFound />} />
    
  2. useNavigate programmatic navigation — Instead of using <Link>, actively navigate within code logic. Suitable for scenarios like "automatically jump back to the homepage after 3 seconds."


Navigate — Route Redirect

<Route path="old-path" element={
    <Navigate replace to="/new-path" />} />

When visiting /old-path, it automatically jumps to /new-path. The replace attribute means replacing the current history entry (the user won't go back to old-path when clicking "back"). Without replace, the history entry is preserved.


Summary

Review the core knowledge you can take away from this project:

  1. HashRouter — Path changes after the # do not trigger a browser refresh, suitable for static deployment
  2. Routes + Route — Declarative route configuration, path matches the URL, element specifies the rendered component
  3. Link replaces <a> — Maintains the SPA experience, no page refresh
  4. :xxx dynamic parameters + useParams() — Parameter names must match the route definition, otherwise you get undefined
  5. Nested routes + <Outlet /> — Parent component writes the common layout, child route content fills the Outlet placeholder
  6. React.lazy + <Suspense> — Load pages on demand, reduce initial screen size
  7. path="*" fallback + useNavigate / Navigate — 404 page + automatic navigation / redirect

Next steps for expansion: add route guards (login authentication), breadcrumb navigation, route transition animations, and integrate Zustand/Redux for global state management.


Complete Project Code

The project code is hosted on Gitee, clone and run immediately:

🔗 Repository Address: gitee.com/dcx2758/ai_doubao_dcx

# Clone the entire repository
git clone [email protected]:dcx2758/ai_doubao_dcx.git

# Enter the project directory
cd ai_doubao_dcx/fe/react/router/react-route-demo

# Install dependencies
npm install

# Start the development server
npm run dev

# Browser access
# http://localhost:5173

Project Structure

react-route-demo/
├── package.json
├── vite.config.js
├── index.html
└── src/
    ├── main.jsx                # Entry file
    ├── App.jsx                 # Route configuration core
    ├── App.css
    ├── index.css
    ├── components/
    │   └── Navigation.jsx      # Navigation bar component
    └── pages/
        ├── Home/
        │   └── index.jsx       # Homepage
        ├── About/
        │   └── index.jsx       # About page
        ├── UserProfile/
        │   └── index.jsx       # User page (dynamic parameter)
        ├── Products/
        │   ├── index.jsx       # Product list (nested route parent component)
        │   ├── ProductDetail/
        │   │   └── index.jsx   # Product detail (child route)
        │   └── NewProduct/
        │       └── index.jsx   # New product (child route)
        └── NotFound/
            └── index.jsx       # 404 page

Tech Stack

Dependency Version
Vite ^8.0.12
React ^19.2.6
react-dom ^19.2.6
react-router-dom ^7.18.2

Run immediately after npm install, no extra configuration needed.


On the path of coding, routing is a hurdle you cannot bypass. I hope this article helps you step on a few fewer pitfalls 🍀 If you find it useful, feel free to like and bookmark it. If you have questions, let's discuss in the comments 👏