Build a small router from scratch, so you know what React Router does. Read the address, change it with no page load, match pages, and handle Back, queries and focus.
In Part 27 we said the page address can hold state, and that routing reads the address. In Part 34 we said each page of an app is a good piece to load on its own. This part builds the thing both of them need: a small router.
A router is the code that looks at the address and picks the page to show. Most apps use a library for this. But a router is smaller than you might think. We’ll build one in about 40 lines, with only React and the browser. Then you’ll know what a library does for you, and where its extra work goes.
We’ll use three things from earlier parts again. The first is a store read with useSyncExternalStore, from Part 25 and Part 27. The second is an event listener with a cleanup, from Part 12. The third is e.preventDefault(), from Part 6.
Try this first
Read this code, but don’t press Run yet.
import { useState, useSyncExternalStore, type MouseEvent, type ReactNode } from 'react'
function subscribe(listener: () => void) {
window.addEventListener('hashchange', listener)
return () => window.removeEventListener('hashchange', listener)
}
function getPath() {
return window.location.hash.slice(1) || '/'
}
function navigate(to: string) {
window.location.hash = to
}
function Link({ to, children }: { to: string; children: ReactNode }) {
function handleClick(e: MouseEvent<HTMLAnchorElement>) {
e.preventDefault()
navigate(to)
}
return <a href={'#' + to} onClick={handleClick}>{children}</a>
}
export default function App() {
const path = useSyncExternalStore(subscribe, getPath)
const [likes, setLikes] = useState(0)
return (
<div>
<nav>
<Link to="/">Home</Link> | <Link to="/about">About</Link>
</nav>
<button onClick={() => setLikes(likes + 1)}>Likes: {likes}</button>
<p>The path is {path}</p>
{path === '/' && <h1>Welcome home</h1>}
{path === '/about' && <h1>About us</h1>}
</div>
)
}
Make a guess. You click Likes three times, so it says 3. Then you click About. Will it still say 3, or will it go back to 0?
Now press Run and try it.
The heading changes to “About us”, and the path changes to /about. But the button still says “Likes: 3”. The page did not load again. If it had, React would have started over, and every piece of state would be back at its first value.
Now click Home, then press your browser’s own Back button. The box goes back to “About us”. The router follows Back too.
One warning for every example here. All the result boxes on this page share one history list with the page itself. Pressing Run again adds an entry too. Then Back may go to the old run, and the box can break. If that happens, press Run to start over.
What an address is made of
Every page on the web has an address, called a URL. Here is one, cut into its parts:
https://shop.example/users/2?tab=posts#reviews
https://shop.examplesays which server to ask./users/2is the path. It says which page on that server you want.?tab=postsis the query string. It holds extra values, askey=valuepairs joined with&.#reviewsis the hash, also called the fragment. On a normal page, it points to a place inside the page.
MDN’s page about the URL says something about the hash that matters for us. The part after the # “is never sent to the server with the request”. The browser keeps it to itself.
What routing means in a React app
On an old-style website, every link asks the server for a new page. The browser throws the old page away and draws the new one. Any state your JavaScript held is gone.
Many React apps work another way. MDN calls this a single-page application, or SPA for short. In MDN’s words, it “loads only a single web document”. The browser loads the page once. After that, your JavaScript changes what the page shows.
But people still want addresses. They want to copy a link to a page and send it to a friend. They want the Back button to work. Routing is how an SPA keeps addresses working. A router has four jobs:
- Read the address, and tell React when it changes.
- Change the address without loading a new page.
- Show the page that matches the address.
- Follow the browser’s Back and Forward buttons.
“Try this first” did all four. Now let’s build each piece properly.
Why the examples use the # part
A real app usually keeps the page in the path, like /about. It changes the path with the browser’s History API. You’ll see that version later in this part.
The result box under each example can’t do that. It is a small page inside this page, kept apart for safety. Its own address is about:srcdoc, not a normal web address. It is also sandboxed: the browser runs it with fewer rights. The box doesn’t get the allow-same-origin right. Without it, MDN says, the page gets “a special origin that always fails the same-origin policy”. An origin is the server part of an address. Here it is null.
So history.pushState with any address throws an error called SecurityError. We tried it in Chromium, Firefox and WebKit, with /about and even with #/about. All three refused both. In our test, the lesson page was at http://localhost:37307/post/, and Chromium said:
Failed to execute 'pushState' on 'History': A history state object with URL 'http://localhost:37307/about' cannot be created in a document with origin 'null' and URL 'about:srcdoc'.
Changing location.hash works in all three. about:srcdoc became about:srcdoc#/about. So the examples keep the path after the #, like #/about. This is called hash routing. Some real apps use it too, because the server never sees the hash. The react-katas site is one. It runs on GitHub Pages, which only sends real files, so its router keeps the page after the #.
The router’s pieces are the same either way. Only the two small functions that read and change the address are different.
Piece 1: a store for the address
The address lives outside React, in the browser. When it changes, React needs to hear about it. That is exactly what useSyncExternalStore is for. In Part 27 we read our own store with it. React’s docs also show it reading a value from the browser. Their example reads whether the network is online.
A store for useSyncExternalStore needs two functions:
function subscribe(listener: () => void) {
window.addEventListener('hashchange', listener)
return () => window.removeEventListener('hashchange', listener)
}
function getPath() {
return window.location.hash.slice(1) || '/'
}
subscribeadds a listener for thehashchangeevent. MDN says this event fires “when the fragment identifier of the URL has changed”.subscribegives back a cleanup function. It removes the same listener, as Part 12 showed.getPathreads the hash.window.location.hashis'#/about', so.slice(1)drops the#and gives'/about'. With no hash at all, it is'', and|| '/'turns that into'/'.
Both functions sit outside any component. React’s docs say why for subscribe. If a component passes a new subscribe function on each render, React “will re-subscribe to the store”. Declared outside the component, it is always the same function.
getPath returns a string. Strings are compared by value, so the same address always gives “the same” snapshot. Part 25 showed what goes wrong when a snapshot is a new object every time.
Then a component reads the path with useSyncExternalStore(subscribe, getPath). We counted the listeners for “Try this first” in jsdom, a browser stand-in that runs in Node.js. There was 1, with Strict Mode on and off.
Piece 2: navigate(to)
function navigate(to: string) {
window.location.hash = to
}
That’s all. Setting location.hash does three things in the browser:
- The address changes at once. Right after the line runs,
location.hashis'#/about'. - The browser adds an entry to its history. History is the list of places this tab has been. Back and Forward move through it.
- A moment later, the browser fires
hashchange. Our listener runs. React callsgetPath, sees a new string, and rendersAppagain.
We measured step 3 in the result box. Right after location.hash = '/x', our listener had run 0 times. 50 ms later, it had run once. Chromium, Firefox and WebKit all did the same.
You can call navigate from anywhere, not only from links. After someone saves a form, you might call navigate('/thanks').
Piece 3: <Link>
Why not write a plain <a href="#/about">? On a normal page, that link changes only the hash. The browser doesn’t load anything.
The result box is not a normal page. A link’s address is worked out from the page that holds the box. So a plain <a href="#/about"> points at this lesson page, with #/about added. We clicked one in Chromium, Firefox and WebKit. All three loaded this whole lesson page inside the result box. The app was gone.
In a real app with paths, a plain <a href="/about"> is worse. It asks the server for a new page and starts the app over. Common mistakes shows what we measured.
So Link takes over the click:
import type { MouseEvent, ReactNode } from 'react'
declare function navigate(to: string): void
function Link({ to, children }: { to: string; children: ReactNode }) {
function handleClick(e: MouseEvent<HTMLAnchorElement>) {
e.preventDefault()
navigate(to)
}
return <a href={'#' + to} onClick={handleClick}>{children}</a>
}
e.preventDefault()stops the browser’s default action for a link, which is to go tohref. Part 6 explained default actions.navigate(to)changes the address our way.
Link still draws a real <a> with a real href. That matters. MDN says pressing Enter on a focused link counts as a click, so the keyboard works. And the browser can still do its own things with links. We’ll see that in the History API version.
Piece 4: matching routes, with params
A route pairs a path pattern with a page. The pattern /users/:id matches /users/1, /users/2 and so on. The :id part is a param: a piece of the path that can change. The router takes out its value and hands it to the page.
Here is a small function that compares a pattern with a path, piece by piece:
type Params = Record<string, string>
function matchPath(pattern: string, path: string): Params | null {
if (pattern === '*') return {}
const want = pattern.split('/').filter(Boolean)
const have = path.split('/').filter(Boolean)
if (want.length !== have.length) return null
const params: Params = {}
for (let i = 0; i < want.length; i++) {
if (want[i].startsWith(':')) {
params[want[i].slice(1)] = decodeURIComponent(have[i])
} else if (want[i] !== have[i]) {
return null
}
}
return params
}
split('/')cuts'/users/2'into['', 'users', '2']..filter(Boolean)drops the empty piece, so we get['users', '2'].- If the two lists have different lengths, it isn’t a match.
nullmeans “no match”. - A piece that starts with
:matches anything. Its value goes intoparams, under the name after the:. - Any other piece must be exactly the same.
'*'matches every path. We put it last, as the “not found” page.
We gave it some pairs. The results:
| Pattern | Path | Result |
|---|---|---|
/users/:id |
/users/2 |
{"id":"2"} |
/users/:id |
/users |
null |
/users/:id |
/users/2/posts |
null |
/ |
/ |
{} |
* |
/oops |
{} |
decodeURIComponent turns %20 back into a space, and so on. An address can’t hold some characters as they are, like a space. So the browser writes them in a coded form, like %20. More on that under Common mistakes.
One warning: decodeURIComponent throws on a broken code. We tried the path /users/50%, with a % and nothing after it. It threw URIError: URI malformed. A real router should catch that error.
Routes tries each route in order and shows the first one that matches:
import { useSyncExternalStore, type MouseEvent, type ReactNode } from 'react'
function subscribe(listener: () => void) {
window.addEventListener('hashchange', listener)
return () => window.removeEventListener('hashchange', listener)
}
function getPath() {
return window.location.hash.slice(1) || '/'
}
function navigate(to: string) {
window.location.hash = to
}
function Link({ to, children }: { to: string; children: ReactNode }) {
function handleClick(e: MouseEvent<HTMLAnchorElement>) {
e.preventDefault()
navigate(to)
}
return <a href={'#' + to} onClick={handleClick}>{children}</a>
}
type Params = Record<string, string>
function matchPath(pattern: string, path: string): Params | null {
if (pattern === '*') return {}
const want = pattern.split('/').filter(Boolean)
const have = path.split('/').filter(Boolean)
if (want.length !== have.length) return null
const params: Params = {}
for (let i = 0; i < want.length; i++) {
if (want[i].startsWith(':')) {
params[want[i].slice(1)] = decodeURIComponent(have[i])
} else if (want[i] !== have[i]) {
return null
}
}
return params
}
type Route = { path: string; page: (params: Params) => ReactNode }
function Routes({ routes }: { routes: Route[] }) {
const path = useSyncExternalStore(subscribe, getPath).split('?')[0]
for (const route of routes) {
const params = matchPath(route.path, path)
if (params) return route.page(params)
}
return null
}
const users: Record<string, string> = { '1': 'Ana', '2': 'Ben', '3': 'Chen' }
function UserPage({ id }: { id: string }) {
const name = users[id]
if (!name) return <h1>No user with id {id}</h1>
return <h1>User {id}: {name}</h1>
}
const routes: Route[] = [
{ path: '/', page: () => <h1>Home</h1> },
{ path: '/users/:id', page: params => <UserPage id={params.id} /> },
{ path: '*', page: () => <h1>Page not found</h1> },
]
export default function App() {
return (
<div>
<nav>
<Link to="/">Home</Link> | <Link to="/users/1">Ana</Link> |{' '}
<Link to="/users/2">Ben</Link> | <Link to="/oops">A broken link</Link>
</nav>
<Routes routes={routes} />
</div>
)
}
Run it and click each link.
- “Ana” shows “User 1: Ana”.
/users/1matched/users/:id, soparams.idis'1'. - “Ben” shows “User 2: Ben”. It is the same route, with a different param.
- “A broken link” shows “Page not found”. Only
'*'matched/oops.
Routes gets the path, drops any query string with .split('?')[0], and asks each route in turn. route.page(params) is a function that gives back the page’s JSX.
Back and Forward
We never wrote any code for the Back button, yet it worked. Here is why.
From a click to a new page, and back again. Press play, or step through it.
- You click a
Linkto/users/2.Linkcallse.preventDefault(), so the browser loads nothing. navigatechanges the address. The browser adds/users/2to the tab’s history.- The store hears about the change. With the hash, the browser fires
hashchange. WithpushState,navigatecalls the listeners itself, as you’ll see below. Either way, React hears about it. - React calls
getPathand gets a new path.Routesrenders again and shows the page for/users/2. - You press Back. The browser moves back one entry in its history. The address is
/again. - The browser fires an event:
hashchangewith the hash,popstatewith the History API. React reads the path again, andRoutesshows Home.
The browser owns the history list. Our router only adds entries, and listens when the address changes. With hash routing, Back and Forward fire hashchange too, so the same listener covers them.
You can move through history from code too. history.back() and history.forward() do what the browser’s buttons do. Here is a small app with steps, like a sign-up form split over pages:
import { useSyncExternalStore } from 'react'
function subscribe(listener: () => void) {
window.addEventListener('hashchange', listener)
return () => window.removeEventListener('hashchange', listener)
}
function getPath() {
return window.location.hash.slice(1) || '/'
}
function navigate(to: string) {
window.location.hash = to
}
export default function App() {
const path = useSyncExternalStore(subscribe, getPath)
const step = path === '/' ? 1 : Number(path.replace('/step/', ''))
return (
<div>
<button disabled={step === 1} onClick={() => window.history.back()}>Back</button>
<button onClick={() => window.history.forward()}>Forward</button>
<h1>Step {step} of 3</h1>
{step < 3 && <button onClick={() => navigate('/step/' + (step + 1))}>Next step</button>}
<p>The path is {path}</p>
</div>
)
}
Run it. Click “Next step” twice, then “Back”, then “Forward”. Try your browser’s Back button too.
The Back button is turned off on step 1. The result box shares its history with this lesson page. We pressed Edit, took disabled out, pressed Run and clicked Back on step 1. In Firefox and WebKit, the tab left this lesson and went to the page before it. Chromium stayed on this page.
Pressing Run again also adds an entry to that shared list. We ran this example, clicked “Next step”, then pressed Edit and Run again. The box showed step 1. Then we pressed the browser’s Back. Chromium showed an error page in the box. Firefox’s box stopped answering. WebKit showed step 2 with the new code. So if the box breaks, press Run to start over.
An everyday example
Think of a stack of paper notes on a desk. Each time you go to a new page, you write its address on a note and put it on top. Back moves you down one note. Forward moves you up again. If you go somewhere new from the middle, the notes above are thrown away. Our router writes notes. The browser keeps the stack.
The exact version
The browser keeps one history list for the whole tab. The result box and this lesson page share it. That’s why Back on step 1 can leave the lesson.
The notes thrown away are real too. We checked in jsdom. We went to step 3, pressed Back twice, then clicked “Next step”. That made a new step 2. After that, Forward did nothing, because the old step 3 entry was gone.
Also, a browser doesn’t always add an entry. We set the hash to /twice, then to /twice again. The first time added 1 entry and fired hashchange once. The second time did nothing at all. All three browsers did the same.
The same router with the History API
In a real app, you’ll usually want clean paths, like /users/2 with no #. The browser’s History API changes the path without loading a page. Two parts of it matter here:
history.pushState(state, '', url)changes the address and adds a history entry.- The
popstateevent fires when you move through history. That can be the Back and Forward buttons, orhistory.back()andhistory.forward()in code.
Here is the router again, with only the address code changed. It can’t run in the result box, as we saw. So it has no Run button. We ran it in a real Vite app in Chromium.
import { useSyncExternalStore, type MouseEvent, type ReactNode } from 'react'
const listeners = new Set<() => void>()
function subscribe(listener: () => void) {
listeners.add(listener)
window.addEventListener('popstate', listener)
return () => {
listeners.delete(listener)
window.removeEventListener('popstate', listener)
}
}
function getPath() {
return window.location.pathname + window.location.search
}
export function usePath() {
return useSyncExternalStore(subscribe, getPath)
}
export function navigate(to: string) {
window.history.pushState(null, '', to)
listeners.forEach(listener => listener())
window.scrollTo(0, 0)
}
export function Link({ to, children }: { to: string; children: ReactNode }) {
function handleClick(e: MouseEvent<HTMLAnchorElement>) {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
e.preventDefault()
navigate(to)
}
return <a href={to} onClick={handleClick}>{children}</a>
}
Four things changed.
navigate calls the listeners itself. MDN says that calling pushState “won’t trigger a popstate event”. So pushState changes the address and tells nobody. Our store keeps its own Set of listeners, as the store in Part 27 did, and calls them. We checked: after pushState('/about'), popstate had fired 0 times.
subscribe listens for popstate too. That covers Back and Forward. MDN says popstate is fired “by doing a browser action such as a click on the back or forward button”. Calling history.back() or history.forward() fires it too. So does setting location.hash: in our tests, every hash change fired popstate first, then hashchange.
navigate scrolls to the top. A new page should start at the top, the way a loaded page does. The browser no longer does this for you, because no page loads. In our Vite app, we scrolled down to a link at the bottom, about 2,450 pixels down, and clicked it. After the click, the page was at 0. That was the same in Chromium, Firefox and WebKit.
Back is different. The browser puts the scroll back by itself, because history.scrollRestoration is "auto". In all three browsers, Back returned to the same place, about 2,450 pixels down. That works here because our pages draw at once.
Link lets some clicks through. People hold Ctrl and click a link to open it in a new tab. If Link called preventDefault then, that would stop working. So when a key like Ctrl is held, Link returns early. The browser then does its normal thing with the real href. e.metaKey is the Command key on a Mac, MDN says. e.button is 0 for the main mouse button, and Link also returns early for any other button. React Router’s own code makes a similar check before it steps in. It also leaves links with a target other than _self to the browser. We held Ctrl and clicked a Link in Chromium. A new tab opened at /users/2, and the first tab stayed on Home.
A middle click, with the mouse wheel, doesn’t even fire click. It fires auxclick, which MDN says is for “any mouse button other than the primary”. We middle-clicked a Link in Chromium. The link got 0 click events and 1 auxclick event, so onClick never ran. A new tab opened at /users/2. So in Chromium, the e.button check never mattered. It is there as a guard, as in React Router.
The server has a job too
With paths, there’s one more thing. Someone can open https://your.app/users/2 directly, or press reload there. Then the browser asks the server for /users/2. Your app has only one HTML file, index.html. So the server must send index.html for every path. Then your router reads the path and shows the right page.
We tested two kinds of server with our Vite app, after a production build. A server that only knew real files answered /users/2 with 404 Not Found. vite preview sent index.html, and the app showed user 2. Vite’s docs call this “SPA fallback”. When you put your app on a real server, check its docs for the same setting.
Hash routing doesn’t need this, because the server never sees the hash. That’s why the react-katas site uses it.
Reading the query string
The query string is a good place for state that someone might want to share, like a search word. Open /search?q=apple, and you see the same search as the person who sent the link. That is the “URL state” from Part 27.
The browser has a tool for it: URLSearchParams. MDN says it has “utility methods to work with the query string of a URL”. Give it the text after the ?, and .get('q') gives you the value of q. Give it an object, and it builds the text for you. Characters that can’t go in an address as they are get their coded form.
import { useState, useSyncExternalStore } from 'react'
function subscribe(listener: () => void) {
window.addEventListener('hashchange', listener)
return () => window.removeEventListener('hashchange', listener)
}
function getPath() {
return window.location.hash.slice(1) || '/'
}
function navigate(to: string) {
window.location.hash = to
}
export default function App() {
const path = useSyncExternalStore(subscribe, getPath)
const [word, setWord] = useState('')
const q = new URLSearchParams(path.split('?')[1]).get('q')
function search() {
navigate('/search?' + new URLSearchParams({ q: word }))
}
function clear() {
setWord('')
navigate('/')
}
return (
<div>
<input aria-label="Search word" value={word} onChange={e => setWord(e.target.value)} />
<button onClick={search}>Search</button>
<button onClick={clear}>Clear</button>
<p>The path is {path}</p>
{q !== null && <p>You searched for: {q}</p>}
</div>
)
}
Run it, type fish & chips, and press Search.
The path becomes /search?q=fish+%26+chips. The space became +, and & became %26. .get('q') turns them back, so the page says “You searched for: fish & chips”. Press Back, and the “You searched for” line goes away, because the query string was the only place it lived. The box still says “fish & chips”. That text is the input’s own state, not the address.
The search word is read from the address on every render. There’s no useState for the results. The address is the state.
Some changes shouldn’t add a history entry. Say you update the address on every key press while someone types. Then Back would step back one letter at a time. MDN says history.replaceState changes the current entry, in place of adding a new one. We called it twice in jsdom, and the history list didn’t grow. Routers also use it when they send you on to another address. Then Back doesn’t land on the old address again.
Layouts: a page inside a page
Many apps have parts that stay while the page below them changes. A users area might show one user, with links to the other users under it. The links stay, and only the user changes. A component that holds the parts that stay is called a layout.
In our router, a layout is just a component that takes children:
import type { ReactNode } from 'react'
type Params = Record<string, string>
type Route = { path: string; page: (params: Params) => ReactNode }
declare function Link(props: { to: string; children: ReactNode }): ReactNode
declare function UserPage(props: { id: string }): ReactNode
function UsersLayout({ children }: { children: ReactNode }) {
return (
<section>
{children}
<p>Other users: <Link to="/users/1">Ana</Link> | <Link to="/users/2">Ben</Link></p>
</section>
)
}
export const routes: Route[] = [
{ path: '/users/:id', page: params => <UsersLayout><UserPage id={params.id} /></UsersLayout> },
]
declare tells TypeScript “this exists somewhere else”, so this piece can be checked on its own.
Going from /users/1 to /users/2 gives UsersLayout at the same place in the tree. So React keeps it and changes only the page inside, as Part 18 explained. We checked: the layout’s <section> was the same element before and after. Its state stays too.
Real routers make this easier. React Router’s docs say “Routes can be nested inside parent routes”. The parent shows the child’s page with an <Outlet> component.
Focus: telling everyone the page changed
Focus is the place on the page where key presses go. A screen reader is a program that reads the page out loud.
Gatsby, a React tool for building websites, wrote about this in 2019. A real page load starts the focus again and tells screen readers about the new page. In an SPA, nothing loads. So “screen reader users may not be informed that the page has changed”. And focus “may be kept in the same place as where they clicked”.
So a good router moves the focus to the new page. Gatsby’s team tested ways to do this with people who have disabilities. Of the ways they tried, “Focusing on a heading was found to be the best experience”. They also found that moving focus back to the top of the app “would be very overwhelming”.
A heading can’t take focus on its own. tabIndex={-1} fixes that. MDN’s page on tabindex says code can still focus such an element, with focus(). But the Tab key never stops on it.
This is the users app again, with focus added, and the layout from above:
import { useEffect, useRef, useSyncExternalStore, type MouseEvent, type ReactNode } from 'react'
function subscribe(listener: () => void) {
window.addEventListener('hashchange', listener)
return () => window.removeEventListener('hashchange', listener)
}
function getPath() {
return window.location.hash.slice(1) || '/'
}
function navigate(to: string) {
window.location.hash = to
}
function Link({ to, children }: { to: string; children: ReactNode }) {
const path = useSyncExternalStore(subscribe, getPath)
function handleClick(e: MouseEvent<HTMLAnchorElement>) {
e.preventDefault()
navigate(to)
}
return (
<a href={'#' + to} aria-current={path === to ? 'page' : undefined} onClick={handleClick}>
{children}
</a>
)
}
type Params = Record<string, string>
function matchPath(pattern: string, path: string): Params | null {
if (pattern === '*') return {}
const want = pattern.split('/').filter(Boolean)
const have = path.split('/').filter(Boolean)
if (want.length !== have.length) return null
const params: Params = {}
for (let i = 0; i < want.length; i++) {
if (want[i].startsWith(':')) {
params[want[i].slice(1)] = decodeURIComponent(have[i])
} else if (want[i] !== have[i]) {
return null
}
}
return params
}
type Route = { path: string; page: (params: Params) => ReactNode }
function Routes({ routes }: { routes: Route[] }) {
const path = useSyncExternalStore(subscribe, getPath).split('?')[0]
const box = useRef<HTMLDivElement>(null)
const shownPath = useRef(path)
useEffect(() => {
if (shownPath.current === path) return
shownPath.current = path
box.current?.querySelector('h1')?.focus()
}, [path])
let page: ReactNode = null
for (const route of routes) {
const params = matchPath(route.path, path)
if (params) {
page = route.page(params)
break
}
}
return <div ref={box}>{page}</div>
}
const users: Record<string, string> = { '1': 'Ana', '2': 'Ben', '3': 'Chen' }
function UsersLayout({ children }: { children: ReactNode }) {
return (
<section>
{children}
<p>Other users: <Link to="/users/1">Ana</Link> | <Link to="/users/2">Ben</Link></p>
</section>
)
}
function UserPage({ id }: { id: string }) {
return <h1 tabIndex={-1}>User {id}: {users[id] ?? 'nobody'}</h1>
}
const routes: Route[] = [
{ path: '/', page: () => <h1 tabIndex={-1}>Home</h1> },
{ path: '/users/:id', page: params => <UsersLayout><UserPage id={params.id} /></UsersLayout> },
{ path: '*', page: () => <h1 tabIndex={-1}>Page not found</h1> },
]
export default function App() {
return (
<div>
<nav>
<Link to="/">Home</Link> | <Link to="/users/1">Users</Link>
</nav>
<Routes routes={routes} />
</div>
)
}
Run it. Click “Users”, then press the Tab key. Focus moves to the “Ana” link, right after the heading, not back to the top. The heading took the focus when the page changed.
Three new things:
Routeswraps the page in a<div>with a ref, as in Part 14. After the path changes, an Effect finds the page’s<h1>and focuses it.shownPathremembers the path that is already on the page. On the first render it is the same aspath, so the Effect does nothing. A page that has just opened shouldn’t move the focus. Strict Mode runs the setup twice on mount, and both times the paths are the same. So this is not the “skip the second setup” trick that Part 14 warned about.Linkaddsaria-current="page"when it points at the page you’re on. It marks that link as the current page. Gatsby’s testers said this “helps in applications”. Part 46 covers ARIA attributes like this one, and Part 47 covers keyboard navigation.
We checked in the result box, in Chromium, Firefox and WebKit. After a click on “Users”, the focused element was the <h1> “User 1: Ana”. On the first Run, focus didn’t move.
Pages that load later: lazy routes
Part 34 said pages are a good place to split code. With our router, a lazy page is just a lazy component in a route:
const SettingsPage = lazy(() => import('./SettingsPage'))
const routes: Route[] = [
{ path: '/', page: () => <h1>Home</h1> },
{ path: '/settings', page: () => <SettingsPage /> },
]
function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Routes routes={routes} />
</Suspense>
)
}
The code for /settings downloads the first time someone goes there. Until it arrives, <Suspense> shows “Loading…”.
There is one catch with our router. Part 34 kept the old page on screen with a Transition while the new one loaded. That doesn’t work here. React’s docs for useSyncExternalStore say why. Changes to the store “cannot be marked as non-blocking Transition updates, so they will trigger the nearest Suspense fallback”.
We tried four routers in jsdom, each with startTransition(() => navigate('/settings')) and a lazy page:
| Router | Right after the click |
|---|---|
History API version, read with useSyncExternalStore |
“Loading…” |
The same, but the path kept in useState, set inside the Transition |
the old page stays |
Hash version, read with useSyncExternalStore |
“Loading…” |
Hash version, the path kept in useState |
“Loading…” |
The first two rows show the store’s limit. The hash rows fail for another reason too. hashchange fires a moment later, after startTransition has already finished. So that update was never inside the Transition.
React Router’s docs say it wraps its own updates in startTransition. They also list this same limit of useSyncExternalStore as a known problem.
What real routers add
Our router is about 40 lines. A real one does much more. Two common choices for React:
- React Router calls itself “a multi-strategy router for React”. You can use it as a small library, like ours, or as a whole framework.
- TanStack Router is built around TypeScript. Its docs say it knows all your routes, with their path params. So TypeScript can check every link.
Things they add that ours doesn’t have:
- Nested routes, with layouts and
<Outlet>, as above. - Data loading. A route gets a loader, a function that gets its data. React Router’s docs say “the loaders are called before the route component is rendered”. TanStack Router lists “Built-in Route Loaders” too. Part 34 showed why waiting in a chain is slow.
- Scroll on Back, for pages that load data. The browser already puts the scroll back on Back, for a simple page like ours, as we saw above. But if the page’s content arrives later, there is nothing to scroll to yet. React Router’s
ScrollRestorationcopies what the browser does with scroll when the address changes, so it can handle that. - Transitions, so the old page can stay while the new one loads.
Use a library in a real app. Build the small one once, and you’ll know what the library does inside.
Common mistakes
Using a plain <a> in a real app
<a href="/about">About</a>
In a History API app, this asks the server for a new page. We tested it in our Vite app in Chromium. We clicked “Likes” three times, then a plain <a href="/about">. The browser loaded the whole page again: 1 new request for the HTML. “Likes” went back to 0. The same click on our Link made 0 page requests and kept “Likes: 3”.
The fix: use Link for every address inside your app. Plain <a> is right for other websites.
Forgetting popstate
const listeners = new Set<() => void>()
export function subscribe(listener: () => void) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
This store hears navigate, so links work. But nothing listens for popstate. We clicked from Home to /about in our Vite app, then pressed Back. The address went back to /, but the page still said “About us”. The fix: add and remove a popstate listener in subscribe, as in the History API version.
Building a URL by joining strings
import { useState, useSyncExternalStore } from 'react'
function subscribe(listener: () => void) {
window.addEventListener('hashchange', listener)
return () => window.removeEventListener('hashchange', listener)
}
function getPath() {
return window.location.hash.slice(1) || '/'
}
function navigate(to: string) {
window.location.hash = to
}
export default function App() {
const path = useSyncExternalStore(subscribe, getPath)
const [word, setWord] = useState('')
const q = new URLSearchParams(path.split('?')[1]).get('q')
return (
<div>
<input aria-label="Search word" value={word} onChange={e => setWord(e.target.value)} />
<button onClick={() => navigate('/search?q=' + word)}>Search</button>
<p>The path is {path}</p>
{q !== null && <p>You searched for: {q}</p>}
</div>
)
}
Run it and search for fish & chips. The page says “You searched for: fish”. The & was taken as the start of a second pair. So q got only 'fish ', and the rest became a key called ' chips' with no value. The browser turned each space into %20, but it left the & alone.
The fix: build the query with new URLSearchParams({ q: word }), as in Reading the query string. For one piece of a path, use encodeURIComponent: '/users/' + encodeURIComponent(name).
Not handling unknown routes
Take the '*' route out of the users example, and click “A broken link”. Routes finds no match and returns null. The page shows only the links, with no message. People will meet old links and typing mistakes. Always end the list with a '*' route.
Practice
Use the “Piece 4” users example for the first three. If a box stops working after you press Run again and Back, press Run to start over.
- Add an
/aboutroute that shows “About us”, and a link to it. - Add a link to
/users/9. What does the page show? Is that the “Page not found” route? - Move the
'*'route to the top of the list. What happens when you click “Ana”? - In the steps example, keep the step in the query string, like
/steps?n=2. UseURLSearchParamsto read it and to build it.
Answers
- Add
{ path: '/about', page: () => <h1>About us</h1> }before the'*'route, and<Link to="/about">About</Link>in the<nav>. If you put it after'*', it never shows, because'*'matches first. - “No user with id 9”. The route
/users/:idmatched, so it’s not the router’s “not found”. The page itself found no user. A real app often has both kinds of message. - Every page shows “Page not found”, even Home when the app starts.
Routesstops at the first match, and'*'matches everything. - Read the step with
const n = new URLSearchParams(path.split('?')[1]).get('n'), thenconst step = n === null ? 1 : Number(n). Build the next address withnavigate('/steps?' + new URLSearchParams({ n: String(step + 1) })). After two clicks the path is/steps?n=3, and Back still works.
Interview questions
Try to answer each one out loud before you open the answer.
What does a router do in a single-page app?
It keeps addresses working when the page never loads again. It reads the address and tells React when it changes. It changes the address without loading a new page. It shows the component that matches the address. And it follows Back and Forward. The browser keeps the history list. The router adds entries to it and listens for changes.
A strong answer adds the server’s part. With paths, the server must send index.html for every path. If it doesn’t, opening such an address directly, or pressing reload, gives a 404.
Does pushState fire popstate?
No. MDN says calling pushState “won’t trigger a popstate event”. popstate fires when the user moves with Back or Forward, or when code calls history.back() or history.forward(). So a router does two things. After pushState, it calls its own listeners. And it listens for popstate, for the browser’s buttons. Forget the first, and links change the address but not the page. Forget the second, and Back changes the address but not the page.
A strong answer adds that pushState doesn’t fire hashchange either, even when only the hash changes. MDN says so on both pages.
Why does a Link component stop the browser’s own click, and when should it not?
A plain link makes the browser load the href as a new page. That starts the app over, and its state is lost. Link calls preventDefault and changes the address itself. But some clicks should stay normal. A click with a key like Ctrl held is left to the browser. So is a click with a button other than the main one. People use those to open a link in a new tab. React Router’s Link makes a similar check, and also leaves links with a target other than _self alone.
A strong answer says to keep a real <a href>. Then “open in new tab” and the keyboard still work.
Hash routing or the History API: what is the difference?
Hash routing keeps the page after #, like /#/users/2. The browser never sends the hash to the server, so any server works with no setup. The History API gives clean paths like /users/2. But then the server must send index.html for every path.
Hash routing fits a host that only sends real files, like GitHub Pages. It also fits a sandboxed frame like our result box. Its origin is null, so any pushState with an address throws a SecurityError, even one that changes only the hash.
Why read the address from a store outside React?
The address lives in the browser, and it changes without React knowing. React’s docs show useSyncExternalStore reading such a browser value. You give it subscribe, which listens for the change, and getPath, which reads the value. Then every component that reads it sees the same address in a render. Part 25 explained why that matters.
A strong answer names the cost. React’s docs say changes to such a store “cannot be marked as non-blocking Transition updates”. So a lazy page shows the Suspense fallback, even inside startTransition. React Router’s docs name this limit, and wrap their own updates in startTransition.
What should happen to focus when the route changes?
Nothing loads, so a screen reader user may not hear that the page changed. Focus may stay on the link they clicked. Move focus to the new page’s heading. Give the heading tabIndex={-1}, so code can focus it. Don’t move focus on the first load. In Gatsby’s tests with users, “Focusing on a heading was found to be the best experience”. Also mark the current link with aria-current="page".
A strong answer adds three things. Set document.title for each page, so the tab’s title matches. An ARIA live region can announce the change too. Gatsby tested one in its test pages. And a lazy page may not have its <h1> yet when the path changes. Then focus must wait until the page is there.
How would you put a search word in the address safely?
Build it with URLSearchParams: '/search?' + new URLSearchParams({ q: word }). Read it back with new URLSearchParams(location.search).get('q'). Joining plain strings breaks when the word holds & or #. For one piece of a path, use encodeURIComponent. Then turn it back with decodeURIComponent when you match the route.
A strong answer adds two things. While someone types, use replaceState, so Back doesn’t step through every letter. And URL.search writes a space as %20, but URLSearchParams writes it as +. MDN warns that the same values can then be written two ways.
Sources
- useSyncExternalStore, react.dev: subscribing to a browser API (
navigator.onLine), “will re-subscribe to the store” and declaringsubscribeoutside the component, and that store changes “cannot be marked as non-blocking Transition updates, so they will trigger the nearest Suspense fallback”. - lazy, react.dev: lazy components.
- What is a URL?, MDN: the parts of a URL, and that the fragment “is never sent to the server with the request”.
- SPA (Single-page application), MDN: “loads only a single web document”.
- History.pushState(), MDN: the new URL must be same-origin or
pushStatethrows, andpushStatenever fireshashchange. - popstate event, MDN: “won’t trigger a popstate event”, and Back and Forward do. hashchange event, MDN: “when the fragment identifier of the URL has changed”.
- URLSearchParams, MDN: “utility methods to work with the query string of a URL”, and spaces as
+whereURL.searchuses%20. - History.replaceState(), History.scrollRestoration and Document.title, MDN.
- <iframe>, MDN: without
allow-same-origin, “a special origin that always fails the same-origin policy”. - click event, auxclick event MouseEvent.metaKey and MouseEvent.button, MDN: Enter on a focused link is a click, other buttons fire
auxclick,metaKeyis the Command key on a Mac, and0is the main button. - tabindex, MDN: a negative value can still be focused by code, but not with the Tab key.
- React Router:
shouldProcessLinkClick(main button, no modifier keys,targetof_self), the home page (“a multi-strategy router for React”), Routing (“Routes can be nested inside parent routes”), Outlet, Data Loading, ScrollRestoration and React Transitions (updates wrapped instartTransition, and theuseSyncExternalStorelimit). - TanStack Router overview: its routes and path params known to TypeScript, and “Built-in Route Loaders”.
- What we learned from user testing of accessible client-side routing techniques, Gatsby blog, 2019: what a page load does for focus and screen readers, focusing a heading, a live region in the prototypes, and
aria-current. - Shared options: appType, vite.dev:
'spa'uses “SPA fallback”. - The react-katas router (
src/router/router.tsx): hash routing, because GitHub Pages sends only real files. This part has no kata of its own. - Every count, error text and browser result above comes from running React 19.3.0 for this post: in jsdom 30.1.2 for the playground examples; in the result box of this page in Chromium 151, Firefox 153 and WebKit 26.5, from Playwright 1.62.1; and in a Vite 8.3.3 production build in Chromium 151.