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:
- What exactly is the difference between
HashRouterandBrowserRouter? - Why doesn't the
<Outlet />for nested routes render even though I wrote it? - I clearly called
useParams(), so why is the value I getundefined? - How do I configure
<Suspense>for route lazy loading without errors?
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:
- A runnable SPA routing project structure
- Practical usage of HashRouter / Routes / Route / Link / Outlet / useParams / Navigate / useNavigate
- The correct combination of route lazy loading + Suspense
- At least 3 pitfalls you wouldn't know without seeing them
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'sreturn, 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 thelazycomponent, otherwise React will throw anA React component suspended while renderingerror.
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:
:productsId— Dynamic parameter, matches/products/123,/products/abc, etc.new— Fixed path, exactly matches/products/new
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 destructuresproductId(without an 's'). The parameter name must be exactly the same, otherwiseproductIdwill always beundefined.
useParams()returns an object whose key is the name after the:in the route.path=":productsId"meansuseParams()returns{ productsId: '123' }, so trying to getproductIdnaturally 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:
path="*"fallback matching — Placed at the end of<Routes>, all unmatched paths will be caught by it:<Route path="*" element={<NotFound />} />useNavigateprogrammatic 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:
- HashRouter — Path changes after the
#do not trigger a browser refresh, suitable for static deployment - Routes + Route — Declarative route configuration,
pathmatches the URL,elementspecifies the rendered component - Link replaces
<a>— Maintains the SPA experience, no page refresh :xxxdynamic parameters +useParams()— Parameter names must match the route definition, otherwise you getundefined- Nested routes +
<Outlet />— Parent component writes the common layout, child route content fills the Outlet placeholder React.lazy+<Suspense>— Load pages on demand, reduce initial screen sizepath="*"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 👏