Robin van der Vleuten

URL state is still state

I've built this screen more times than I can count: a table of orders, a search field, a status filter, maybe a date range, a few tabs across the top, pagination at the bottom.

Nothing dramatic.

You wire it up with React state because that is the obvious place for state to go:

jsx
const [status, setStatus] = useState("open")
const [page, setPage] = useState(1)
const [query, setQuery] = useState("")

It works, right up to the moment someone refreshes the page and loses the filter they were looking at. Or sends a link to a colleague and the colleague lands on the default view. Or clicks the back button expecting to undo the last filter change and leaves the screen entirely.

The problem isn't React state. Some state just belongs in the URL.

The state you can point at

Not every bit of interface state deserves a URL. Whether a dropdown is open? Probably not. The current value of an unfinished form field? Usually no. The hover state of a button? Please don't.

But if the state changes what the page means, the URL should at least be considered.

Things like:

  • the active tab
  • search queries
  • filters
  • sorting
  • pagination
  • selected date ranges
  • compact vs detailed views

Those are more than UI details. They are part of the resource the user is looking at. If I'm looking at paid invoices from March on page three, that should be something I can bookmark, reload, or send to someone else.

The URL is how the page makes that state portable.

The hidden cost of keeping it local

Here is the kind of component I used to write without thinking much about it:

jsx
function OrdersPage() {
const [status, setStatus] = useState("open")
return (
<>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="open">Open</option>
<option value="paid">Paid</option>
<option value="cancelled">Cancelled</option>
</select>
<OrdersTable status={status} />
</>
)
}

There is nothing wrong with this in isolation. The problem is where the memory lives.

The state only exists as long as the component does. Refresh the page and you're back to open. Copy the URL and the current filter disappears. Open the same screen in a new tab and you start over.

The interface remembers. The page doesn't.

Let the URL carry the boring parts

The browser already has a place for this kind of state: query parameters.

text
/orders?status=paid&page=3

It is not glamorous, but it is useful. You can read it on load, update it when filters change, and let the browser do what browsers are good at: preserve location.

A tiny version can look like this with URLSearchParams:

jsx
function readFilters() {
const params = new URLSearchParams(window.location.search)
return {
status: params.get("status") || "open",
page: Number(params.get("page") || "1"),
}
}

Now the component starts from the URL instead of inventing its own private truth:

jsx
function OrdersPage() {
const [filters, setFilters] = useState(readFilters)
function updateFilters(nextFilters) {
const params = new URLSearchParams()
params.set("status", nextFilters.status)
params.set("page", String(nextFilters.page))
window.history.replaceState(null, "", `?${params.toString()}`)
setFilters(nextFilters)
}
return (
<>
<select
value={filters.status}
onChange={(event) =>
updateFilters({ status: event.target.value, page: 1 })
}
>
<option value="open">Open</option>
<option value="paid">Paid</option>
<option value="cancelled">Cancelled</option>
</select>
<OrdersTable status={filters.status} page={filters.page} />
</>
)
}

Notice the small detail when the status changes: the page goes back to 1.

Changing the status invalidates the current pagination, so reset it. That rule is easier to see when filters are treated as one piece of state instead of scattered across a component.

Push or replace?

There are two browser APIs you will usually reach for:

js
window.history.pushState(null, "", url)
window.history.replaceState(null, "", url)

The difference is whether you want a new browser history entry. For pagination, pushState often feels right. If a user clicks from page one to page two, the back button taking them back to page one makes sense.

For every keystroke in a search field, pushState is usually awful. Nobody wants to press back fifteen times because they typed "invoice". That's where replaceState is useful: it updates the URL without turning every tiny change into a history event.

A decent rule of thumb:

Use pushState for deliberate navigation. Tabs, pagination, saved views, anything that feels like moving to another version of the page.

Use replaceState for refinement. Search text, sliders, fast filters, and anything that changes too often to deserve its own back-button stop.

You will still make judgement calls. That's fine. Just make them deliberately.

URL state is shareable state

Once filters live in the URL, a few things get better without adding much code.

Reloads stop being destructive. The user can refresh the page and stay exactly where they were.

Links become useful again. Support can ask for a URL and see the same filtered view. A teammate can send "the cancelled orders from last week" without writing instructions.

Browser history starts making sense. The back button can move through meaningful states instead of leaving the screen unexpectedly.

Debugging gets easier. You can paste a URL into a fresh tab and reproduce the same state without clicking through the UI again.

That's a lot of value for a query string.

Don't put everything there

The URL is not a dumping ground. I have seen people push entire form drafts into query parameters, encode blobs of JSON, or store state that only makes sense for a single render.

That way lies sadness.

A URL should be readable enough that a human can squint at it and understand the shape of the page:

text
/orders?status=paid&page=3&sort=created_at

Readable. Useful.

This is not:

text
/orders?state=eyJ0YWJzIjpbeyJpZCI6...

At that point you are not using the URL so much as smuggling an application store through it.

Use the URL for state that describes the view. Keep short-lived interaction state in React. Keep private or sensitive state out of it entirely.

The useful question

When I'm not sure whether something belongs in the URL, I ask one question: would it be useful if this survived a refresh?

If the answer is yes, it probably should not live only in component state:

  • Filters? Yes.
  • Current page? Usually.
  • Open modal? Maybe.
  • Hovered row? Absolutely not.

That question has saved me from a surprising amount of awkward UI. URL state is not fancy. It just makes the page explain itself.

Good URLs are for people using the thing, not for routers and crawlers alone. Filters survive reloads. Shared links open the right view. The page keeps enough context to be useful when you come back to it.

Just a page you can point at.