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