50 Lines of Code That Demystify React Router: A Frontend Routing Epiphany
I Rewrote the Core of React Router in 50 Lines of Code, and Finally Understood Frontend Routing Principles
Author: kyriewen Tags: Frontend, JavaScript, React.js
I've used React Router for years, always in a state of "I can use it but I don't understand the principles." The differences between
<Route>,useNavigate,hashandhistorymodes — I memorized them, but I couldn't explain how they actually work under the hood. Last weekend, I spent two hours writing a 50-line mini router from scratch. After finishing it, I had that "oh, that's all it is" epiphany. This article shares the complete code and thought process, to help you reach that "aha moment" too.
Before Writing, Think Through 4 Questions
Many people approach reading source code or hand-writing by jumping straight into coding. After doing that, they can't remember anything.
The right approach is to think through the design first, then write code. I asked myself 4 questions:
- What is the essence of frontend routing? — Listen for URL changes → Match the corresponding handler → Render content. At its core, it's an event-driven mapping table.
- What exactly is the difference between hash and history? — hash uses the
hashchangeevent, history uses thepopstateevent, but the API should be unified — the caller shouldn't care about the underlying mechanism. - Which scenarios are easy to miss? — Direct URL access (initial load), browser forward/back buttons, dynamic route parameters (
/user/:id). - Is the history mode 404 on refresh a frontend or backend problem? — It's a backend problem. Frontend routing only handles URL changes during JS runtime. A refresh is a real request sent by the browser, requiring a server-side fallback.
Once you think through these 4 questions, you can basically write the code.
50 Lines of Code Implementation
First, the complete code, then a section-by-section explanation.
class Router {
constructor(mode = 'hash') {
this.mode = mode;
this.routes = new Map();
this.currentPath = '';
this._bindEvents();
}
_bindEvents() {
if (this.mode === 'hash') {
window.addEventListener('hashchange', () => {
this._handleRoute(location.hash.slice(1) || '/');
});
} else {
window.addEventListener('popstate', () => {
this._handleRoute(location.pathname);
});
}
}
route(path, handler) {
this.routes.set(path, handler);
return this;
}
_handleRoute(path) {
this.currentPath = path;
const handler = this._matchRoute(path);
if (handler) handler(path);
}
_matchRoute(path) {
if (this.routes.has(path)) return this.routes.get(path);
for (const [pattern, handler] of this.routes) {
const regex = new RegExp('^' + pattern.replace(/:\w+/g, '([^/]+)') + '$');
if (regex.test(path)) return handler;
}
return this.routes.get('*');
}
push(path) {
if (this.mode === 'hash') {
location.hash = path;
} else {
history.pushState(null, '', path);
this._handleRoute(path);
}
}
start() {
const path = this.mode === 'hash'
? (location.hash.slice(1) || '/')
: location.pathname;
this._handleRoute(path);
}
}
Exactly 50 lines (excluding blank lines). Breakdown below.
Block 1: Constructor and Event Binding
constructor(mode = 'hash') {
this.mode = mode;
this.routes = new Map();
this.currentPath = '';
this._bindEvents();
}
Using a Map for the route mapping table instead of a plain object. Interview bonus point: Map keys maintain insertion order, iterating over them is ordered; plain objects don't guarantee this.
_bindEvents() {
if (this.mode === 'hash') {
window.addEventListener('hashchange', () => {
this._handleRoute(location.hash.slice(1) || '/');
});
} else {
window.addEventListener('popstate', () => {
this._handleRoute(location.pathname);
});
}
}
The core difference between the two modes is right here:
| Mode | Event Listened | URL Format | Server Configuration Needed |
|---|---|---|---|
| hash | hashchange |
example.com/#/about |
No |
| history | popstate |
example.com/about |
Yes |
Common interview follow-up: "When does popstate fire?"
Answer: It fires when the browser's forward/back buttons are used. pushState and replaceState do not trigger popstate, so in the push method, _handleRoute must be called manually.
Block 2: Route Matching
_matchRoute(path) {
// Exact match
if (this.routes.has(path)) return this.routes.get(path);
// Dynamic route matching (parameters like :id)
for (const [pattern, handler] of this.routes) {
const regex = new RegExp('^' + pattern.replace(/:\w+/g, '([^/]+)') + '$');
if (regex.test(path)) return handler;
}
// Fallback: 404
return this.routes.get('*');
}
These 10 lines do three things:
- Exact match:
/aboutprecisely matches/about - Dynamic parameters:
/user/:idmatches/user/123 - 404 fallback: Unmatched paths go to the
*wildcard
Interview bonus point: The regex /:\w+/g converts :id to ([^/]+). An interviewer might ask "how's the performance of this regex?" — Answer: Route counts are typically 20-50, so regex matching overhead is negligible. For thousands of routes (unlikely), a Trie tree could optimize.
Block 3: Navigation and Startup
push(path) {
if (this.mode === 'hash') {
location.hash = path; // Automatically triggers hashchange
} else {
history.pushState(null, '', path);
this._handleRoute(path); // pushState doesn't trigger popstate, call manually
}
}
start() {
const path = this.mode === 'hash'
? (location.hash.slice(1) || '/')
: location.pathname;
this._handleRoute(path);
}
push is programmatic navigation, corresponding to Vue Router's router.push() and React Router's navigate().
start handles initial load — when a user directly accesses a page via URL, the current route needs to be matched immediately.
Usage
const router = new Router('history');
router
.route('/', () => {
document.getElementById('app').innerHTML = '<h1>Home</h1>';
})
.route('/about', () => {
document.getElementById('app').innerHTML = '<h1>About Us</h1>';
})
.route('/user/:id', (path) => {
const id = path.split('/').pop();
document.getElementById('app').innerHTML = `<h1>User ${id}</h1>`;
})
.route('*', () => {
document.getElementById('app').innerHTML = '<h1>404 Page Not Found</h1>';
});
router.start();
// Programmatic navigation
document.getElementById('btn').addEventListener('click', () => {
router.push('/about');
});
Chained calls, clean API, basically consistent with the usage experience of Vue Router / React Router.
6 Questions You'll Be Asked in Interviews
Once you understand the principles, you don't need to memorize these common interview questions — you can derive the answers from the principles.
Q1: "How to choose between hash mode and history mode?"
hash mode:
✅ No server configuration needed
✅ Good compatibility (IE9+)
❌ URL has #, not aesthetically pleasing
❌ Not SEO-friendly
history mode:
✅ Clean URLs
✅ SEO-friendly
❌ Page refresh causes 404 (needs server fallback)
❌ Requires IE10+
One-liner answer: Use hash for admin panels, use history for user-facing products.
Q2: "How to fix the history mode 404 on refresh?"
Add one line to Nginx config:
location / {
try_files $uri $uri/ /index.html;
}
All unmatched paths fall back to index.html, letting the frontend router take over.
Q3: "If there are many routes, will matching performance be an issue?"
In real projects, routes typically don't exceed 50. Linear traversal has no performance problem. For extreme scenarios needing optimization, a Trie tree (prefix tree) can be used for route matching, reducing time complexity from O(n) to O(m), where m is the path depth.
Q4: "How does your router support route guards?"
Add a beforeEach hook:
beforeEach(guard) {
this._guard = guard;
return this;
}
_handleRoute(path) {
if (this._guard && !this._guard(this.currentPath, path)) return;
this.currentPath = path;
const handler = this._matchRoute(path);
if (handler) handler(path);
}
Usage:
router.beforeEach((from, to) => {
if (to === '/admin' && !isLoggedIn()) {
router.push('/login');
return false;
}
return true;
});
Q5: "How to extract dynamic route parameters?"
In the current implementation, parameters must be manually parsed from the path. An optimized version:
_matchRoute(path) {
for (const [pattern, handler] of this.routes) {
const keys = [];
const regex = new RegExp(
'^' + pattern.replace(/:\w+/g, (_, key) => {
keys.push(key);
return '([^/]+)';
}) + '$'
);
const match = path.match(regex);
if (match) {
const params = Object.fromEntries(keys.map((k, i) => [k, match[i + 1]]));
return () => handler(params);
}
}
return this.routes.get('*');
}
This way, the handler receives structured parameters like { id: '123' }.
Q6: "Are the implementation principles of Vue Router and React Router the same as yours?"
The core principle is the same — both listen for URL changes via hashchange / popstate. But they add a lot of engineering:
- Nested routes: Support for route tree structures
- Lazy loading:
React.lazy+Suspense/ Vue's() => import() - Transition animations: Animation hooks during route changes
- Scroll restoration: Restoring scroll position on forward/back navigation
But the underlying principle is exactly these 50 lines of code.
Why Hand-Write When AI Can Generate It in a Second?
It's true — Claude Code can give you a complete routing implementation with one sentence. But there are two things it can't give you:
Understanding: If the AI-generated routing code has a bug, can you debug it? If you don't understand that
popstatedoesn't respond topushState, you might search for hours without finding the problem.Design judgment: When starting a new project, should you choose hash or history? When should you use nested routes? Where should route guards be placed? These decisions require understanding the underlying principles to make correctly.
Use AI to write code, hand-write to learn principles. They're not contradictory.
Final Thoughts
React Router's source code has thousands of lines, but the core principle is these 50 lines. The extra code handles engineering concerns like nested routes, lazy loading, scroll restoration, SSR, etc.
Once you understand the core, looking at the source code won't leave you confused.
If you also have framework APIs you "can use but don't understand the principles," try spending two hours hand-writing a minimal version. The epiphany after finishing is more effective than reading ten articles.
What other frontend knowledge have you "used for a long time but can't explain the principles of"? Leave a comment — we can cover it next time.