跪拜 Guibai
← Back to the summary

The One Question That Prevents Most React Router Layout Bugs

Table of Contents


Opening: An Unexpected Title

You are building an e-commerce admin backend. The product module has two pages: Product Detail and Add New Product. You naturally write routes like this:

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

The Products component contains a title:

const Products = () => (
  <>
    <h1>Product List</h1>
    <Outlet />
  </>
)

Looks fine. But when you run it—

The Add New Product page also has the big "Product List" heading. You just wanted to add a form in NewProduct, so why is there an extra title?

This is the most classic problem in nested routing: the parent route's UI is "inherited" by all child routes. This article solves that problem.


Core Concept: The Mental Model of Nested Routing

<Outlet /> is a "slot" that React Router opens in the parent component. The currently matched child route component automatically renders into this position.

Let's look at a completely analogous real-life scenario:

You have an exhibition hall (parent route /products), and on the wall hangs a digital screen (<Outlet />). The hall itself has a fixed sign that reads "Product List."

The problem is right here: Should the sign belong to the hall? If not—if it belongs to the "Product Detail" exhibition area—it shouldn't be hung on the hall's wall.

Translating back to the React world:

Hall (Parent Route) Digital Screen (Outlet) Sign (UI Ownership)
Put things all child pages need here Put the current child route's content here Whoever needs it, put it there

Diagram: The URL-to-Component Mapping Process

When you visit /products/123, React Router's matching process looks like this:

graph TD
    A["URL: /products/123"] --> B["Match /products Route"]
    B --> C["Render Products component (parent)"]
    C --> D["Parent component contains h1 + Outlet"]
    D --> E["Continue matching /products/:productId"]
    E --> F["Render ProductDetail component"]
    F --> G["ProductDetail replaces the Outlet position"]
    G --> H["Final output: h1 Product List + h3 Product Detail-123"]

When you visit /products/new:

graph TD
    A["URL: /products/new"] --> B["Match /products Route"]
    B --> C["Render Products component (parent)"]
    C --> D["Parent component contains h1 + Outlet"]
    D --> E["Continue matching /products/new"]
    E --> F["Render NewProduct component"]
    F --> G["NewProduct replaces the Outlet position"]
    G --> H["Final output: h1 Product List + h1 NewProduct<br/>⚠️ The title shouldn't be here!"]

Comparing the two diagrams makes the problem clear at a glance: The parent route's h1 treats both child routes "equally." It doesn't know which child needs it and which doesn't.


Step-by-Step Breakdown: From Route Configuration to Page Rendering

Layer 1: Route Configuration

// App.jsx — Routes are like a tree
<HashRouter>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/about" element={<About />} />
    <Route path="/user/:id" element={<UserProfile />} />

    {/* 🔑 Nested routes: products is the parent, the two inside are children */}
    <Route path="/products" element={<Products />}>
      <Route path=":productId" element={<ProductDetail />} />
      <Route path="new" element={<NewProduct />} />
    </Route>

    <Route path="*" element={<NotFound />} />
  </Routes>
</HashRouter>

Note that the child route's path is a relative path (":productId" instead of "/products/:productId"), because they are written inside the parent <Route> tag. React Router automatically concatenates the full path.

Layer 2: Parent Component (Layout Container)

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

const Products = () => <Outlet />
// 🔑 Put nothing here, just act as a routing container. Shared UI is zero, all child pages are free to do their own thing.

This is the most minimalist form of a layout component—a pure container, zero intrusion. Child routes are completely independent and do not affect each other.

If the product module needs a sidebar in the future, and every child page needs it, then add it back into Products:

const Products = () => (
  <div className="products-layout">
    <Sidebar />      {/* All product child pages have this */}
    <Outlet />       {/* Child page content goes here */}
  </div>
)

The judging standard: After adding it, ask yourself—"Does this UI appearing on /products/new make sense?" If it doesn't, move it down.

Layer 3: Child Components

// pages/products/Detail/index.jsx — Product detail page needs the "Product List" title
const ProductDetail = () => {
  const { productId } = useParams()
  return (
    <>
      <h1>Product List</h1>
      <h3>Product Detail-{productId}</h3>
    </>
  )
}
// pages/New/index.jsx — Add new page does not need it
const NewProduct = () => <h1>NewProduct</h1>

The difference is: the "Product List" title is placed inside the component that actually needs it, not in the parent route.


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

This is a decision every React project faces. A table makes it clear:

Scenario Put in Parent Route (Layout) Put in Child Route (Page)
Navbar/Breadcrumbs ✅ All child pages need it ❌ Only some pages need it
Page Title ✅ All pages share the same title ✅ Title varies by page
Sidebar ✅ Unified for the entire module ❌ Different pages have different sidebars
Form Content ❌ Irrelevant to the parent route ✅ The page's own business
Data Fetching ❌ Data differs for each page ✅ Use useParams to get id, then fetch

Core Principle:

Parent routes hold the "framework," child routes hold the "content." If a piece of UI is not needed by all child routes, it should not be in the parent route.

Practical method: Put everything in child routes first, and lift it up only when you find duplication. This is the same line of thinking as lifting state in React—except you are lifting UI instead of state.


Supporting Capabilities: Making Routes "Alive"

Nested routing is the backbone of this demo, but there are several equally important supporting capabilities that make routes actually "usable."

Route Lazy Loading: Don't Download Until Visited

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

const Home = lazy(() => import('./pages/Home'))
const ProductDetail = lazy(() => import('./pages/products/Detail'))

// Wrap all Routes in Suspense
<Suspense fallback={<div>Loading...</div>}>
  <Routes>{/* ... */}</Routes>
</Suspense>

lazy() makes each page component download on demand. When a user visits the homepage, the code for About, Products, and other pages is not loaded at all. For medium-to-large applications with dozens of pages, this is a key optimization for first-screen speed.

Dynamic Route Parameters: Get Variables from the URL

// /user/123 → id = "123"
const { id } = useParams()
console.log(id) // "123"

When used with nested routing, useParams can get parameters at the currently matched level. If there are child routes under /products/:productId, the child components can also use useParams to get productId.

Programmatic Navigation: Navigate Without Using Link

// 404 page automatically redirects to homepage after 3 seconds
const navigate = useNavigate()
useEffect(() => {
  setTimeout(() => navigate('/'), 3000)
}, [])

404 Fallback: Put path="*" at the End

<Route path="*" element={<NotFound />} />

⚠️ This route must be placed at the very end of <Routes>, because React Router matches from top to bottom. Placing * at the front would swallow all requests.


Conclusion: Remember One Principle

Going back to the opening problem: The "Product List" title appeared on the Add New page because it was placed in the parent route, and the parent route's UI renders indiscriminately for all child routes.

Fixing it takes only one step: move the title from <Products /> to <ProductDetail />. But what's truly important is the judgment behind it.

Parent routes hold the framework, child routes hold the content. If unsure, put it in the child route first—lift it up only when duplication occurs.

The next time you write nested routes, every time you add a piece of UI to a parent component, ask yourself one question: "Does this UI appearing on every single child page make sense?"

After asking this, most layout mistakes won't happen.

One final thought question for you: If your product module has 5 child pages, 3 of which need the same filter bar, and the other 2 do not—where would you put the filter bar? Feel free to discuss your solution in the comments.