React Router and History API: How Do SPAs Switch Pages?

There is a common mistake Chris makes when building a website with React for the first time. To navigate between pages, he uses the standard HTML <a> tag.
// ❌ Don't do this
<a href="/about">Go to About Page</a>The moment he clicks the link, the screen flashes white, and the entire page refreshes. The React state he was managing so carefully is completely reset and lost. He intended to build an SPA (Single Page Application), but he made it behave like a traditional MPA (Multi Page Application).
What we want is magic: "The URL changes, but the page does not refresh."
Today, we dig into the History API and the principles of React Router, the core browser technologies that make this magic possible.
1. The SPA Dilemma: Tricking the URL
As the name suggests, an SPA has only one HTML page (index.html). However, users want to navigate between various "pages" like /home, /about, and /profile.
Here, a contradiction arises:
To solve this problem, HTML5 introduced the History API.
2. The Engine: History API (pushState)
The browser's window object contains a history object that manages the session history. The core method that React Router relies on is pushState.
// browser-console.ts
// 1. Current address: /home
// 2. Execute pushState (data, title, new URL)
window.history.pushState({ page: 'about' }, '', '/about');
// 3. Result: The address bar changes to /about.
// ✨ IMPORTANT: The browser DOES NOT send a request to the server, nor does it refresh!This method is essentially commanding the browser: "Quietly change the text in the address bar, but don't load anything."
However, this alone is not enough. The address bar has changed, but the actual screen (View) remains the same. We need a "watcher" to detect that the address has changed and swap the components accordingly.
3. The Driver: React Router
React Router is a library that wraps the History API and connects it to React's state. Its operating principle is simpler than you might think.
The Principle in Code
// MiniRouter.tsx (Conceptual Implementation)
import { useState, useEffect } from 'react';
function MiniRouter() {
const [path, setPath] = useState(window.location.pathname);
useEffect(() => {
// Event listener to detect the Back button (popstate)
const onPopState = () => setPath(window.location.pathname);
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
const navigate = (url: string) => {
window.history.pushState({}, '', url); // 1. Change URL
setPath(url); // 2. Change State -> Trigger Re-render
};
return (
<div>
<button onClick={() => navigate('/home')}>Home</button>
<button onClick={() => navigate('/about')}>About</button>
{/* 3. Swap component based on current path */}
{path === '/home' && <Home />}
{path === '/about' && <About />}
</div>
);
}4. Deployment Caution (The Refresh Problem)
Chris deployed the app, which worked perfectly on the development server (localhost), to a real server (S3, Nginx, etc.). However, when he accessed the /about page directly or refreshed the browser, he got a 404 Not Found error.
Why?
Because the browser requested a file named /about from the server. However, the server only has one file: index.html. A folder or file named /about does not physically exist.
Solution: Fallback Configuration
You must configure the server to "return index.html unconditionally, no matter what path comes in."
Once index.html loads, JavaScript executes, looks at the current URL (/about), and renders the appropriate page.
# nginx.conf example
location / {
try_files $uri $uri/ /index.html;
}Key Takeaways
Now we know the principles of page navigation. But where should we handle data fetching or DOM manipulation within a component? It is time to properly handle the most used, yet most misunderstood hook: useEffect.
Continuing in: "The Lies of useEffect's Dependency Array: Solving Stale Closures and When to Use useLayoutEffect”