AlgoInsight

Free lesson · React Deep Dive

URL as State: The Routing Mental Model

Hard9 min readreactjavascriptfrontend

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:

Things that don't belong in URL state:


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:

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:

Applying the framework:

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:

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:

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:

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:

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 useSearchParams properly 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.

Back to React Deep Dive