跪拜 Guibai
← Back to the summary

Frontend Routing Is Three Objects Tricking the Browser

Frontend routing is really just three objects putting on a show

The first time I opened the React Router docs, my mind went completely blank. I had only one question:

"Isn't the URL managed by the backend? What business does the frontend have messing with it?"

This question stumped me for quite a while. Later, I figured it out, and looking back, those React Router APIs don't need to be memorized at all — they just make sense. Today I'm sharing that "aha" moment with you.

First, we need to rewire our brains: URL does not equal backend endpoint

You open your browser, type https://taobao.com/shop/123, and hit Enter.

Instinctively, you think: the browser sends a request to the backend, and the backend returns the page based on /shop/123. That's correct — in traditional websites, every time the URL changes, the browser obediently sends an HTTP request.

But Single Page Applications (SPAs) don't play that game.

An SPA has only one HTML file from start to finish: index.html. Whether you visit /, /shop/123, or /user/profile, the backend throws the same index.html back at you, with a massive chunk of JavaScript hidden inside. Then that JS takes over everything in the browser.

So the question becomes: if there's only one page, what's the point of changing the URL?

The answer is — the URL is no longer used to request resources from the backend; it's used to tell the frontend "which page the user wants to go to."

Put bluntly, the URL has transformed from a "resource locator" into a "frontend state label."

This is the entire reason frontend routing exists. Every API that follows grows from this root.

A brutally honest truth: frontend routing is a complete "con" from start to finish

The browser has an ironclad rule: you can't casually change the address bar URL with JS without triggering a page refresh. This is to protect you — otherwise, malicious sites could treat your address bar like a toy.

Yet frontend routing's whole purpose is to "change the URL without refreshing." So how? Developers found two "backdoors":

Backdoor One: Hash (#)

The stuff after # in a URL is called the hash, or anchor. When this changes, the browser by default does not refresh the page. This is a design baked into the browser from birth, not a bug.

So you can play around behind the #: //#/shop/#/user/123. The browser doesn't bat an eye, no refresh. The frontend just listens for the hashchange event, notices the hash changed, and renders the corresponding component.

That's exactly how HashRouter works. It exploits the browser's anchor mechanism.

Backdoor Two: History API

HTML5 opened another backdoor for developers: history.pushState() and history.replaceState(). These two methods can "secretly" change the address bar URL, and the browser does not refresh at all.

You call history.pushState(null, '', '/shop'), the address bar instantly becomes /shop, but the browser sends no request to the backend. The frontend listens for the popstate event, notices the URL changed, and swaps out the component itself.

BrowserRouter is based on this principle.

At the end of the day, whether it's Hash or the History API, the essence is "fooling" the browser — making the browser think the URL changed when in reality no network request happened. Frontend routing isn't a real page navigation at all; it's an elaborately designed optical illusion.

Once you understand this "con," React Router's APIs become easy to grasp

React Router, put simply, wraps the above illusion into three objects. Once you figure out what each of these three objects does, no API needs memorizing.

navigator — the navigator, handles "where to go"

<Link>, useNavigate, <Navigate> — despite different names, they all operate on the same thing under the hood: the navigator.

// Declarative: place a link, click it to go to /about
<Link to="/about">About</Link>

// Imperative: user logged in successfully, quickly yank them away
const navigate = useNavigate();
navigate('/dashboard', { replace: true });

// Conditional: not logged in? directly render a "navigation instruction"
<Navigate to="/login" replace />

Three writing styles, one core action: push a new record into the browser's history stack (or replace the current one), then notify React Router "time to swap components."

You might ask: why not just use a regular <a> tag?

Because <a> is the browser's own child, its behavior is hardcoded: send HTTP request → receive HTML → refresh the entire page. All your SPA state vanishes, all that effort wasted.

location — location info, handles "where am I now"

It's the "structured translation" of the URL:

{
  pathname: '/shop/123',      // path
  search: '?color=red',       // query parameters
  hash: '#size',              // anchor
  state: { from: '/checkout' } // hidden state (invisible in the URL)
}

Call useLocation(), and you know in your component "which page the user is on right now."

The most easily overlooked field here is state. It doesn't show its face in the URL but can quietly pass data across pages. The classic use case is remembering "where the user came from":

// Inside ProtectRoute
<Navigate to="/login" state={{ from: "/pay" }} />

// Inside Login
const location = useLocation();
const from = location.state?.from || "/";
// After successful login, jump back to from

Without state, you'd have to stuff the source page into the URL's query parameters — long, ugly, and sensitive info fully exposed.

history — the history stack, handles "where did I come from, can I go back"

The browser's history is a stack structure. Every time you open a new page, one record is pushed onto the top; click "back," and one record pops off the top.

React Router's useNavigate operates on exactly this stack:

navigate('/shop');      // push — push a new record, user can click back to return
navigate('/shop', { replace: true }); // replace — replace current record, back button won't bring them back

When to use replace? When redirecting from a login page. The user just successfully logged in — letting them click "back" only to return to the login form is just torture. No normal person needs the "go back to login page" operation.

Auth-guarded routing is simply stringing these three objects together

Any login interception logic can be condensed into one sentence:

If the user isn't logged in, navigate to the login page, while remembering the current page address; once login succeeds, navigate back based on that address.

Translated into code:

// ProtectRoute — uses navigator + location
const ProtectRoute = ({ children }) => {
  const isLogin = localStorage.getItem('isLogin') === 'true';
  const location = useLocation(); // check where we are now

  if (!isLogin) {
    // navigate to login page, conveniently bring current location along
    return <Navigate to="/login" replace state={{ from: location.pathname }} />;
  }

  return children;
};

// Login — uses navigator + location.state
const Login = () => {
  const navigate = useNavigate();
  const location = useLocation();
  const from = location.state?.from || '/'; // where did we come from?

  const handleLogin = () => {
    // ...validate username and password
    localStorage.setItem('isLogin', 'true');
    navigate(from, { replace: true }); // navigate back, replace prevents going back to login page
  };
};

Each line isn't hard on its own. But if you don't hold the three roles — navigator, location, history — in your head, they easily become confusing when tangled together.

Dynamic routes and nested routes: how a URL becomes a component tree

The sentence in React Router that gave me the biggest epiphany is: route configuration is matching a URL pattern against a component tree.

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/user/:id" element={<UserProfile />} />
  <Route path="/products" element={<Products />}>
    <Route path=":productId" element={<ProductDetail />} />
    <Route path="new" element={<NewProduct />} />
  </Route>
  <Route path="*" element={<NotFound />} />
</Routes>

/user/123 → matches /user/:id → renders <UserProfile />, parameter id = "123" retrievable via useParams().

Nested routes are slightly trickier: /products/123 first matches the parent route /products, rendering <Products />; then matches the child route :productId, stuffing <ProductDetail /> into the <Outlet /> slot inside the parent component.

<Outlet /> is just a "child route slot." Same idea as Vue's <router-view>, just a different name.

Dynamic routes have one particularly easy pitfall: matching order follows declaration order, not specificity. /products/new and /products/:productId — whichever is written first matches first. If you put :productId first, then "new" gets treated as a parameter value, not a path. So concrete paths must always be written before dynamic paths. React Router v6+ claims it auto-sorts, but I still don't trust it — every time I manually put the concrete ones first, for peace of mind.

One last thing: HashRouter URLs are genuinely ugly

Links from HashRouter look like this: https://xx.com/#/user/123

BrowserRouter looks like this: https://xx.com/user/123

"Ugly" isn't really the key point. The key is semantics.

The URL is one of the interfaces your system uses to communicate with users. /user/123 clearly tells the user: "You are viewing user 123's page." /#/user/123 looks more like: "You are in an app internal state called user/123."

When you copy a link to a colleague for debugging, or a user saves it as a bookmark, the difference between these two semantics becomes starkly obvious.

So, unless your deployment environment is so peculiar that you can't configure nginx (like some bizarre static hosting platforms), choose BrowserRouter without a second thought. That # — we can do without it if at all possible.


Alright, to wrap up:

navigator handles "how to go," location handles "where am I now," history handles "can I go back."

All of React Router's APIs are, frankly, just syntactic sugar for these three concepts. You're not memorizing React Router's documentation; you're understanding how the browser manages navigation state. Once this clicks, routing holds no more secrets in your eyes.