Skip to the content.

The URL is state too — and often the right place for it

The URL is state too — and often the right place for it

The address bar is a state container, and it is one developers routinely ignore. Filters, the active tab, a search query, the current page of results, a sort order — teams stash all of these in a component’s useState or a Redux slice, and in doing so throw away three things the URL gives for free: the state survives a reload, it can be bookmarked, and it can be shared. The test is simple: if a user would reasonably want to bookmark, share, or reload back into this exact view, that view’s state belongs in the URL. Put it in local state instead and you have built a view no one can link to and everyone loses on refresh.

Local state is lost on reload and share; URL state survives both Left: filters in local state, a reload or a shared link loses them. Right: the same filters in the query string, so reload and share reproduce the exact view. local state filter=shoes, page=3 (in memory) reload / share back to defaults — lost URL state ?filter=shoes&page=3 reload / share exact same view — restored
The same filter state in memory evaporates on reload or when shared; in the query string it reproduces the exact view for the next load and the next person.

Read and write the query string as the source of truth

The pattern is to treat the URL’s search params as the state for these values — read from them to render, write to them to change — so there is no second copy to keep in sync:

import { useSearchParams } from "react-router-dom";

function ProductList() {
  const [params, setParams] = useSearchParams();
  const filter = params.get("filter") ?? "all";     // state READ from the URL
  const page = Number(params.get("page") ?? 1);

  const setFilter = (value) =>
    setParams((p) => { p.set("filter", value); p.set("page", "1"); return p; });  // WRITE to the URL

  return <>{/* render from `filter`/`page`; changing them updates the address bar */}</>;
}

Now a reload re-reads the same params, and copying the URL to a colleague reproduces the exact filtered, paginated view.

Push vs replace: mind the history

One nuance separates a good URL-state implementation from an annoying one: whether a change adds a history entry (push) or overwrites the current one (replace). Navigations the user should be able to go back through — opening a product, moving to page 2 — should push. High-frequency changes — typing in a search box, dragging a slider — should replace, or the back button becomes useless, undoing one keystroke at a time:

// a search-as-you-type field: replace, so Back doesn't step through every letter
setParams(next, { replace: true });

What belongs in the URL, and what does not

The line is about shareability and durability, not about being global. Put in the URL anything that defines a view worth returning to: filters, sort, tab, search query, pagination, a selected item’s id. Keep out of the URL things that are transient or private: a dropdown’s open state, an unsaved form draft, ephemeral hover state, and anything sensitive (a URL leaks into history, logs, and referrers). The happy consequence of getting this right is that a lot of “state management” simply disappears — you were about to build a filter store, and the address bar was the store all along. The query-string-state exercise builds exactly this read/write-the-URL loop, and design-search-experience is where push-vs-replace and shareable results make or break the feature.