跪拜 Guibai
← All articles
React · ReactRouter · SPA · FrontendRouting

React Router from Scratch: Mapping Every API Back to a 60-Line HashRouter

By 嘟嘟0717 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Many developers treat React Router as opaque framework magic. Mapping every API back to a 60-line hashchange router collapses that abstraction: HashRouter is just addEventListener, Link is just preventDefault, and Outlet is just a div that gets its children swapped. Understanding this makes debugging route mismatches, nested outlet failures, and lazy-loading timing trivial rather than mysterious.

Summary

A step-by-step walkthrough builds a React Router application while keeping a 60-line handwritten HashRouter as the mental model. Every React Router concept — HashRouter wrapping hashchange, Link intercepting anchor clicks, Routes replacing a routing table lookup, useParams parsing the hash, Outlet serving as a nested mount point — is traced back to its vanilla JavaScript equivalent. The correspondence demystifies the library: there is no magic, only the same browser events and DOM manipulation packaged as declarative components and hooks.

The project structure demonstrates lazy loading with React.lazy and Suspense, showing exactly when dynamic imports fire and why synchronous imports bloat the initial bundle. A full click-to-render simulation walks through hashchange firing, Routes iterating, Suspense showing fallbacks, and nested Outlets resolving — all without a single HTTP request. A final concept-mapping table and a ten-question checklist serve as a self-assessment for anyone who wants to stop treating React Router as a black box.

Takeaways
HashRouter internally wraps window.addEventListener('hashchange', …) and passes routing state down via React Context — the same mechanism as a hand-rolled hashchange listener.
Link renders a plain <a href="#/…"> but calls e.preventDefault() in its onClick handler, then manually sets window.location.hash to trigger a client-side route change without an HTTP request.
Routes iterates its child Route elements in written order and renders only the first match; path="*" must come last or it greedily swallows all preceding routes.
useParams destructures dynamic segments (:id, :productId) from the current route path; the parameter name in the destructuring must match the colon-prefixed placeholder in the Route's path attribute.
lazy(() => import('./Page')) defers the network request until the component is actually rendered; without it, all page bundles download on initial load regardless of whether the user navigates to them.
Navigate with the replace attribute erases the current history entry so the back button skips the redirect origin; omitting replace creates an infinite back-button loop.
Outlet is the nested-routing mount point — functionally identical to a <div id="container"> that gets its inner content swapped when child routes match, while the parent layout stays intact.
Module-level code in a lazily imported file (like a console.log outside the component function) executes at import time, not render time, adding to the cost of every lazy load.
useNavigate() changes the hash internally without a page refresh; window.location.href triggers a full browser navigation and HTTP request, breaking the SPA model.
The NotFound component's useEffect timer is never cleared on unmount, so navigating away within 3 seconds still triggers a deferred navigate('/') call — a cleanup opportunity.
Conclusions

React Router's design is fundamentally a 1:1 declarative wrapper around three browser primitives: hashchange events, anchor tag interception, and location.hash manipulation. The library adds no new browser capability — it only repackages existing ones as React components.

The decision to alias HashRouter as Router is a practical abstraction seam: swapping to BrowserRouter later requires changing only the import statement, not the component tree, because both conform to the same Router interface.

Lazy loading's real cost is not the download but the module-level code execution at import time. A lazily loaded component still runs all its top-level statements the moment the file arrives, which means heavy initialization in module scope defeats the purpose of code splitting.

The nested route design — parent Route wrapping child Routes, with Outlet as the insertion point — mirrors the DOM tree itself. This makes route configuration visually isomorphic to the component hierarchy, reducing the mental gap between routing logic and rendered output.

SPA routing's defining characteristic is not client-side rendering but the absence of HTTP requests during navigation. The entire flow from Link click to component swap happens without a single network round-trip to the server, which is what makes it feel instantaneous compared to multi-page apps.

Concepts & terms
hashchange event
A browser event that fires when the fragment identifier (the part after #) of the URL changes. HashRouter relies on this event to detect navigation without triggering a full page reload, since changing the hash does not send an HTTP request.
React.lazy
A React function that takes a factory returning a dynamic import() promise and returns a component that suspends rendering until the module loads. The import is only triggered when the component is first rendered, enabling code splitting.
Outlet
A React Router component that acts as a placeholder in a parent route's layout. When a child route matches, its element is rendered at the Outlet's position, allowing the parent's surrounding UI to persist across child route changes.
useParams
A React Router hook that returns an object of key-value pairs parsed from the current URL's dynamic path segments. The keys correspond to the colon-prefixed placeholders (e.g., :id) defined in the matching Route's path.
Navigate (component)
A React Router component that, when rendered, immediately triggers a client-side redirect to a new location. The replace prop controls whether the current history entry is replaced or a new one is pushed.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗