React Router v7: Route Tables Become Component Trees
Background
The previous article implemented a HashRouter in twenty lines of code: a route table object, a hashchange listener, and a DOM mount point. Put these three things together, and the skeleton of an SPA router is up and running.
A hand-written router can run, but it's still some distance from a real project. An application with slightly more complex pages will successively run into these problems:
- The route table is a flat key-value pair; page nesting relationships cannot be expressed.
- URL parameters (like
/user/123) need to be parsed manually. - Navigation links still use
<a>tags, requiring manual management of styles and active states. - All page code is bundled into a single file, making the initial page load progressively slower.
- Common requirements like 404 fallbacks, redirects, and permission guards need to be rewritten each time.
These problems are not unique to Hash routing—switching to a History API implementation would encounter the same issues. What React Router does is encapsulate these common requirements into a composable API using a component-based approach.
Based on the react-router-demo project, this article uses React Router v7 to break down layer by layer how a component-based routing solution is organized.
Route Configuration: Writing the Route Table as a Component Tree
When handwriting a HashRouter, the route table is an object:
this.routers = {
'/page1': callback1,
'/page2': callback2,
'/page3': callback3,
};
React Router turns the route table into a component tree. Opening App.jsx, the route configuration part looks like this:
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/user/:id" element={<UserProfile />} />
<Route path="/products" element={<Products />}>
<Route path=":productsId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Router>
<Routes> is the container for the route table, and <Route> is a single route configuration. There are several noteworthy design choices in the syntax:
- Declarative: The route structure is written directly in JSX, sharing the same nesting relationship as the component tree. The path hierarchy (
/products→:productsId) corresponds to the nesting level of<Route>, eliminating the need to maintain a separate route mapping table. - The
elementprop receives JSX: Each matched path directly renders the corresponding React component, making the mapping between routes and views clear at a glance. - The
:idsyntax marks dynamic parameters: Segments in the path starting with:are dynamic parameters, which React Router automatically parses and exposes to the corresponding component.
Moving from "object key-value pairs" to a "component tree" represents a core change: route configuration shifts from data to structure—nesting relationships, parameter conventions, and render targets are all written within the same declarative structure, no longer scattered across a route table, a parameter parser, and a render entry point.
Navigation Links: Replacing <a> Tags with Components
In the hand-written solution, navigation links directly use <a href="#/page1">. This works, but the default behavior of an <a> tag is to trigger a full page navigation. React Router needs to intercept this behavior on click and switch to client-side routing instead.
Navigation.jsx uses the <Link> component:
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/123456">Product Detail</Link></li>
<li><Link to="/products/new">New Product</Link></li>
</ul>
</nav>
);
}
The <Link> component does three things under the hood:
- Renders an
<a>tag, ensuring correct semantics and that default browser behaviors like right-click to open in a new tab are unaffected. - Intercepts the click event, preventing the browser from initiating an HTTP request.
- Calls the History API to update the address bar, triggering React Router's internal route matching logic.
The value of the to prop uses the same path rules as <Route path>, so there's no need to manually concatenate a #/ prefix in every href like in the hand-written solution.
Dynamic Route Parameters: useParams Extracts Variables from the URL
/user/123 and /user/456 should render the same UserProfile component, just displaying different data. This requires extracting 123 from the URL and passing it to the component.
The approach in UserProfile/index.jsx:
import { useParams } from 'react-router-dom';
function UserProfile() {
let { id } = useParams();
return <h2>UserProfile: {id}</h2>;
}
The :id in the path /user/:id and the id field returned by useParams() are automatically matched by parameter name. Similarly, the parameter for /products/:productsId in ProductDetail is extracted using the same hook:
const { productsId } = useParams();
This is more direct than manually split('/') and then fetching by index in a hand-written solution—the parameter name is the variable name. The route configuration and component value retrieval align through a naming convention, requiring no extra parsing steps.
Nested Routes: <Outlet> Marks the Content Outlet
/products is a product list page, and /products/123 is a detail page for a specific product. The layout for the detail page is "list page content + detail content," rather than a completely new page.
In Products/index.jsx, the <Outlet> component marks the render position for child routes:
import { Outlet } from 'react-router-dom';
const Products = () => {
return (
<>
<h1>Product List</h1>
<Outlet />
</>
);
};
When the URL matches /products, <Outlet /> renders nothing (no matching child route). When the URL matches /products/123, <Outlet /> renders <ProductDetail />. When the URL matches /products/new, <Outlet /> renders <NewProduct />.
Nested routes solve a specific problem: pages have a hierarchical relationship, but you don't want to repeat the parent layout at every level. <Outlet> makes the parent route component a "container" for the child route component; the parent is responsible for the common parts (title, sidebar), and the child is responsible for the differentiated content.
Corresponding back to the route configuration in App.jsx:
<Route path="/products" element={<Products />}>
<Route path=":productsId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
The path of a child <Route> is automatically concatenated with the parent path: /products + :productsId yields the full path /products/:productsId. The nesting level synchronizes with the rendering level, keeping the path structure and component tree structure consistent.
Route Lazy Loading: Pages Downloaded on Demand
A hand-written HashRouter doesn't have a bundling problem—each HTML page is inherently a separate file. The situation is the opposite for SPAs: all page code is bundled into a single JS bundle by default, meaning users download all page code when visiting the homepage.
React provides lazy and Suspense for code splitting, and React Router does not interfere with this process—it only cares about route matching, not how a component is loaded. The two are used together:
import { lazy, Suspense } from 'react';
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/Detial'));
const NewProduct = lazy(() => import('./pages/Products/New'));
lazy(() => import('./pages/Home')) tells the bundler (Vite / Webpack) to split the Home component's code into an independent chunk. This chunk is only downloaded and executed when the route matches /.
<Suspense fallback={<div>Loading...</div>}> wraps the entire route area, displaying a loading state while the lazy-loaded component's code is still being transferred over the network:
<Router>
<Suspense fallback={<div>Loading...</div>}>
<Navigation />
<Routes>
{/* ... */}
</Routes>
</Suspense>
</Router>
The benefit here is that the route configuration itself doesn't need to change—the syntax <Route element={<Home />}> remains the same. Lazy loading is a change in how the component is loaded, transparent to the routing layer.
Programmatic Navigation and Fallback Handling
Besides clicking a <Link>, sometimes you need to trigger navigation within code logic—for example, redirecting to a list page after a form submission succeeds, or automatically jumping back to the homepage after 3 seconds.
NotFound/index.jsx uses useNavigate:
import { useNavigate } from 'react-router-dom';
const NotFound = () => {
let navigate = useNavigate();
useEffect(() => {
setTimeout(() => {
navigate('/');
}, 3000);
}, []);
return <>404 Not Found</>;
};
useNavigate() returns a function; calling it triggers a client-side route transition, equivalent to clicking a <Link to="/">. The difference from directly manipulating window.location.href is that the latter triggers a full page reload, while navigate() only switches routes on the client side.
The 404 fallback route is implemented using path="*"—* is a wildcard that matches all paths not matched by preceding routes. Placing it at the very end of <Routes> ensures it is only entered when all other routes fail to match.
Looking Back: From Hand-Written Routing to Component-Based Routing
Comparing the HashRouter from "Part One" with React Router in this article, the core difference is not in functionality—a hand-written version can also handle parameter parsing, nesting, and lazy loading—but rather in the method of organization:
| Dimension | Hand-Written HashRouter | React Router |
|---|---|---|
| Route Table | Object key-value pairs | Component tree |
| Parameter Extraction | Manual split | useParams() hook |
| Navigation Links | <a href="#/..."> |
<Link to="/..."> |
| Nested Routes | Manually manage parent-child relationships | <Route> nesting + <Outlet> |
| Lazy Loading | Naturally split by file (MPA) | lazy() + Suspense |
| Navigation | location.hash = '...' |
navigate() |
In the hand-written solution, the route control logic is imperative—you tell the browser what to do at each step: listen for this event, get this hash, look up this object, call this callback. React Router transforms the control logic into a declarative style—you describe what the routes look like: this path corresponds to this component, this parameter is called this name, this position is the child route outlet.
Both approaches have their applicable scenarios. Hand-written routing is suitable for understanding principles, lightweight demos, or framework-agnostic contexts. In real projects, component-based routing saves a large amount of repetitive edge-case handling—parameter parsing, active state management, nesting hierarchy maintenance, code splitting strategies—things that aren't complex to write once, but when written for every page, the cumulative workload exceeds the integration cost of the routing library itself.
Summary
React Router's routing solution can be broken down into three layers:
- Declaration Layer:
<Routes>and<Route>describe the mapping relationship from paths to components; the configuration serves as documentation. - Navigation Layer:
<Link>anduseNavigateprovide two ways to trigger route switching—declarative and programmatic. - Data Layer:
useParamsand<Outlet>are responsible for passing information from the URL to components and mapping the nested structure to the rendering hierarchy.