Free lesson · React Deep Dive
URL as State: The Routing Mental Model
Lesson 20.1 - URL as State: The Routing Mental Model
The conceptual foundation of the module.
Before tools, the mental model. The URL is state. Not just navigation, not just a string in the address bar - actual application state, serialized into a string that the browser persists, displays, and remembers. The history stack is a sequence of past states. The back button is "go to previous state." The refresh button is "restore from this state." Bookmarks are "save this state." Sharing a URL is "send your state to someone else."
This mental model changes how you think about state in a React app. Some state should be in component state (useState). Some should be in client state (Zustand, Redux, etc., from M18). Some should be server state (TanStack Query, from M19). And some should be in the URL itself. This lesson covers when URL state is the right answer.
The framing for the lesson: most apps under-use URL state. Engineers default to component state or global stores for things that should be in the URL. The result is apps where the back button does the wrong thing, refresh loses progress, and sharing a link sends a useless landing page. Internalize URL as state and your apps get substantially better - same effort, dramatically better UX.
The Properties of URL State
A URL has specific properties that distinguish it from other kinds of state:
flowchart TB
URL["URL"]
Bookmark["Bookmarkable<br/>(user can save it)"]
Share["Shareable<br/>(URL can be sent to others)"]
Refresh["Refresh-survivable<br/>(reload restores state)"]
History["In history<br/>(back button works)"]
Index["Indexable<br/>(SEO, search engines)"]
Visible["Visible<br/>(user sees it)"]
URL --> Bookmark
URL --> Share
URL --> Refresh
URL --> History
URL --> Index
URL --> Visible
style URL fill:#a78bfa,stroke:#8b5cf6,color:#000
A diagram of the properties of URL state. The URL is bookmarkable - users can save it and return later, restoring the application state. It's shareable - copying a URL and sending it to another person lets them see exactly what you see. It's refresh-survivable - reloading the page restores the same state, not a default home state. It's in browser history - the back and forward buttons navigate through past states the user has visited. It's indexable - search engines can crawl URLs, so URL-encoded states become discoverable. And it's visible - users can see the URL, can edit it, can understand where they are. Each of these properties is a UX feature. State that lives only in component memory or in a client state library has none of these properties. The decision to put state in the URL is a decision to give that state these properties.
Each property is something users expect. When a piece of state has these properties, users can do things they expect to be able to do. When state lacks these properties, users get frustrated - "I clicked back and lost my filters," "I bookmarked the page but it loads to the home view."
Things that benefit from being in URL state, just from these properties:
- Current route. Obvious - the URL is what tells the app what to show.
- Filters in a list view. Users want to share filtered views.
- Pagination position. "Page 3 of users" is shareable.
- Sort order. Same.
- Search query. Even if it's an in-app search, putting the query in URL makes results bookmarkable.
- Selected tab. Users want to share "the API tab of this docs page."
- Modal state in some cases. "Open the create-user modal" is reasonable URL state if shareability matters.
- Form drafts (sometimes). For long multi-step forms, putting the current step in URL helps with back-button UX.
Things that don't belong in URL state:
- Hover states. Ephemeral.
- Input focus. Ephemeral.
- Form input values during typing. Local UI state until submission.
- Loading states. Derived from network, not user choice.
- Animation states. Ephemeral.
- Theme preferences. Generally per-user, persisted via localStorage or server.
The Decision Framework
A specific question to ask of any piece of state: "Should this survive a refresh?"
If yes - URL state, localStorage, or server-persisted.
If no - component or client state library.
A second question for the yes cases: "Should this be shareable with another user?"
If yes - URL state.
If no - but should survive refresh - localStorage or server.
This produces a decomposition:
- Should survive refresh AND be shareable → URL state.
- Should survive refresh, NOT shareable (preferences, drafts) → localStorage or server.
- Should NOT survive refresh → component or client state library.
Most engineers use this decomposition implicitly but inconsistently. Making it explicit produces better decisions.
A Worked Example
A specific case to make this concrete. A users-management dashboard with:
- Current route (the page itself).
- A search filter ("users matching 'Olena'").
- A status filter ("show only active users").
- Sort order ("by last login, descending").
- Pagination ("page 2 of 5").
- A selected user (showing their details in a side panel).
- The current input value in the search box as the user types.
- A loading indicator while data fetches.
- The user's preferred dark/light theme.
Applying the framework:
- Current route → URL. The path itself.
- Search filter → URL (search param). Users share filtered URLs.
- Status filter → URL (search param). Same reason.
- Sort order → URL (search param). Same.
- Pagination → URL (search param). "Page 2 of filtered active users" is a state worth sharing.
- Selected user → URL (could be a sub-route
/users/:idor a search param?selected=123). Users share specific user views. - Current search input value → component state. Updates on every keystroke; only commits to URL on submit/debounce.
- Loading indicator → derived from server state (TanStack Query's
isFetching). - Theme preference → localStorage. Per-user; doesn't need to be in URL.
The URL ends up looking like:
/users?q=Olena&status=active&sort=lastLogin:desc&page=2&selected=123
This URL, copied and pasted, takes another user (with the right permissions) to exactly that view. The back button navigates through filter changes meaningfully. Refresh restores the exact state. Search engines indexed an empty users page once, see the framework. All from following the framework.
The same app with everything in client state:
/users
User clicks back, all filters reset. User shares URL, recipient sees no filters. User refreshes, loses position. Same functionality; substantially worse UX.
The difference is one architectural decision applied consistently - put what should be shareable in the URL.
A Counterargument and Its Limits
A pushback worth addressing. "If everything goes in the URL, the URL gets huge and ugly."
Two things to know:
1. The URL doesn't need to be pretty for it to work. Most users don't read URLs carefully. They share them as opaque tokens. A URL of /users?q=Olena&status=active&sort=lastLogin:desc&page=2 is functional even if not aesthetically minimal. URLs in modern web apps are routinely long; users don't notice unless they're typing them by hand.
2. URLs can use the hash or compressed forms. For exceptionally long state, encoding to a hash fragment (which doesn't trigger server requests) or base64-encoded blob is an option. Rare; usually not needed.
The pragmatic answer: err toward putting more in the URL, not less. The UX gains compound. If a specific URL gets uncomfortably long, you have options to address it then. Don't preemptively avoid URL state to keep URLs minimal - you'll lose UX wins.
A Subtle Point: URL State Is Public
A specific consideration. URL state can be seen - by the user, by anyone they share the URL with, by browser history, by network logs, sometimes by analytics. Don't put sensitive data in URL state.
Sensitive data that shouldn't be in URLs:
- Personal information that shouldn't leak - full names of other users in some apps, sensitive search queries, etc.
- Auth tokens. Never. Use HTTP-only cookies or secure storage.
- Internal identifiers if they're sensitive. Some apps use opaque public IDs for URLs and sensitive IDs internally.
For the common cases (filters, pagination, search), the data is the user's own input. Putting it in URLs is fine. For cases where you'd be uncomfortable having the URL logged, the data shouldn't be there.
Browser History Mechanics
A useful detail for understanding URL state. The browser's history is a stack:
- Initial page load creates the first entry.
- Each navigation pushes a new entry.
- The back button pops to the previous entry.
- Forward goes back to the popped entry (until a new push, which clears the forward stack).
React Router uses the browser's History API - pushState, replaceState. This means SPA navigation works without page reloads while still using browser history. The URL changes, history entries are added, the back button works.
Two kinds of navigation:
Push - adds a history entry. The default. Each navigation is a new state the user can go back from.
Replace - changes the URL without adding a history entry. Useful for "this change shouldn't be a back-able step."
Examples of when to use replace:
- After login redirect ("don't go back to the login page").
- After redirecting from a deprecated route to its new path.
- When a filter change shouldn't be in history (sometimes - debatable).
For most navigation, push is correct. Replace is for specific UX reasons.
The SPA vs Multi-Page Distinction
A historical note. In multi-page applications, every URL change is a server round-trip - the server returns the HTML for that URL. Routing is the server's job.
In single-page applications (SPAs), navigation is client-side. The URL changes; React Router intercepts; the app renders the matching route without a server round-trip. Faster than multi-page apps for navigation; but the routing is now your problem.
The mental model: you're reimplementing what the browser used to do. The browser had a built-in concept of "navigate to URL." You're hooking into that and providing your own implementation that's faster (no round-trip) but otherwise mimics the browser's behavior.
To mimic correctly:
- Back button must work. ✓ React Router does this.
- Refresh must restore state. ✓ if your routing reads from the URL.
- Bookmarks must work. ✓ if URLs are stable identifiers.
- URL shows what's displayed. ✓ if you keep URL in sync.
When any of these breaks, your app is misbehaving from the browser's perspective. Users will notice, even if they can't articulate it. The discipline of URL-as-state is what keeps these working.
A Specific Pattern: URL as Source of Truth
A pattern I'd recommend internalizing. When state lives in the URL, the URL is the source of truth. Components read from the URL; they don't keep their own copy.
// ❌ Anti-pattern - duplicating URL state in component state
function UsersList() {
const [searchParams] = useSearchParams()
const [filter, setFilter] = useState(searchParams.get('filter') ?? '')
// Now filter and URL can drift. Bug city.
}
// ✅ Pattern - URL is the only source
function UsersList() {
const [searchParams, setSearchParams] = useSearchParams()
const filter = searchParams.get('filter') ?? ''
const setFilter = (newFilter: string) => {
setSearchParams(prev => {
prev.set('filter', newFilter)
return prev
})
}
// filter is always what the URL says. No drift possible.
}
The duplicate-in-state pattern is tempting because it's familiar. Resist it. URL state needs URL to be the single source. Components read from URL; updates write to URL; React re-renders with the new value.
This is the same discipline as M18.7's "derived state shouldn't be stored." Same principle, applied to URL state - don't mirror URL state into component state; just read from the URL.
A Note on Performance
A specific consideration. Reading from the URL is fast - it's just a string parse. But it's not free if you do it carelessly.
Some specific patterns:
Debouncing URL updates. For search inputs, you don't want to update the URL on every keystroke (each one is a history entry; back button is a mess). Debounce - update the URL after the user pauses typing. Use component state for the input value, push to URL when they pause.
replaceState for rapid changes. When sequential changes shouldn't all be in history (the user is dragging a slider that updates the URL), use replace instead of push.
Memoize derived values. If you compute something expensive from URL params on every render, memoize. The URL parsing itself is cheap; whatever you derive from it might not be.
For most apps, these aren't issues you need to think about. They become relevant when URL changes are very frequent (sliders, real-time search) and the derived state is expensive.
What I Want You to Take From This Lesson
Six things.
One: URL is state, serialized into a string. Treat it as a first-class part of your application state.
Two: URL state has specific properties - bookmarkable, shareable, refreshable, in history, indexable, visible.
Three: The decision framework: "should this survive refresh?" Yes → URL or persistent. "Should this be shareable?" Yes → URL.
Four: Most apps under-use URL state. Default to putting more in the URL, not less.
Five: Don't put sensitive data in URLs. They're visible and logged.
Six: URL is the source of truth. Don't duplicate URL state in component state.
Try It
Exercise: Warmup (20 minutes). Audit a project for state that should be in the URL but isn't. Filters, pagination, sort order, search queries. Identify three to four candidates.
Exercise: Core (30 minutes). Pick one of the candidates. Migrate it from component state to URL state (just sketch the pattern; you'll learn
useSearchParamsproperly in 20.6). Note the UX wins.
Exercise: Core (30 minutes). Walk through your app's main flows with the back button. Note where it does the wrong thing. Each wrong-thing is a URL state question.
Exercise: Stretch (45 minutes). Read Kent C. Dodds's "The State Reducer Pattern with React Hooks" piece on URL as state. Map its examples to projects you know.
What's Next
Lesson 20.2 covers React Router fundamentals - the actual API for declaring routes, navigating, and reading params.