URL as state
Put shareable, navigational state — filters, active tab, page, search query — in the URL via search params instead of useState, so it survives refresh, is linkable, and gets back/forward for free; keep transient high-frequency UI state out of it.
A user filters a product list down to “shoes, under $50, sorted by rating”, finds the perfect pair, and sends the link to a friend. The friend opens it and sees the unfiltered grid. The filters vanished on the share — and they’d vanish again on refresh, and the browser Back button does nothing useful. The state was real; it just lived in useState, which is private to one render of one tab.
Colocation taught you to push state down to the component that owns it. This lesson pushes a specific class of state out — into the URL. Some state isn’t really component state at all: it’s a description of what the user is looking at, and the URL is the one place that description is shareable, bookmarkable, and survives a reload. Knowing which state belongs there is a senior-level call.
After this lesson you can tell navigational/shareable state (filters, active tab, page number, search query) apart from transient UI state; read and write that state through search params with useSearchParams, treating the URL as the single source of truth; explain the three things the URL gives you for free — refresh-survival, linkability, and Back/Forward history; and name the failure mode of overusing it: pushing every keystroke or hover into the URL, which spams history and re-renders the tree on each change.
Some state describes what the user is looking at — that state belongs in the URL, not useState. A filter selection, the active tab, the current page, the search query: these all answer “what view is this?”. The URL exists precisely to encode the current view as an addressable, copy-pasteable string. Putting that state in useState makes it invisible to the address bar — so it can’t be shared, bookmarked, or restored.
// useState: the view is private to this render. Refresh and it's gone;
// copy the URL and the recipient sees a different view.
const [category, setCategory] = useState("all");
const [sort, setSort] = useState("popular");The test: if I copied this URL into another tab, should the page look the same? If yes, that state belongs in the URL.
Read the state from search params, and treat the URL as the source of truth — not a copy of useState. With the App Router, useSearchParams returns a read-only URLSearchParams; you derive your view directly from it. There is no second useState mirroring the value, so there is nothing to keep in sync.
"use client";
import { useSearchParams } from "next/navigation";
function ProductList() {
const params = useSearchParams();
const category = params.get("category") ?? "all"; // URL is the source
const sort = params.get("sort") ?? "popular";
return <Grid items={query(category, sort)} />; // UI = f(URL state)
}Notice the shape: UI = f(state) still holds, but state now lives in the URL. The component is a pure function of the search params — refreshable and linkable because its entire input is in the address bar.
Write the state by updating the URL — a navigation — so Back/Forward and refresh come for free. To change the filter you push a new URL; the router re-renders the page with new search params, and the browser records a history entry. You never call setState for this value. Build the next query string from the current params so you change one key without clobbering the rest.
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
function CategoryFilter() {
const params = useSearchParams();
const router = useRouter();
const pathname = usePathname();
function setCategory(next: string) {
const q = new URLSearchParams(params); // start from current URL
q.set("category", next); // change one key
router.push(`${pathname}?${q}`); // a navigation, not setState
}
return <Select value={params.get("category") ?? "all"} onChange={setCategory} />;
}Because each change is a real navigation, the Back button steps to the previous filter and a reload restores the exact view — behaviour you’d otherwise have to rebuild by hand with useState + localStorage + effects.
The failure mode: pushing transient, high-frequency state into the URL spams history and re-renders on every change. The URL is the right home for committed, low-frequency view state. It is the wrong home for the per-keystroke value of an input, a hover, a half-open dropdown, or a drag position. Each router.push is a navigation: it adds a history entry (so one search “shoes” becomes five Back presses — s, sh, sho, sho e, shoes) and re-renders the route subtree on every change.
// WRONG: every keystroke is a navigation — history is now junk, and the
// whole route re-renders on each character.
function Search() {
const router = useRouter(), pathname = usePathname();
return <input onChange={(e) => router.push(`${pathname}?q=${e.target.value}`)} />;
}The fix is split storage by frequency: keep the live input value in local useState (transient, fast, private), and commit to the URL only on a debounce or on submit — and use router.replace (no history entry) instead of push for incremental commits. The URL holds the committed query; useState holds the keystrokes.
A product filter moved from useState to the URL — gaining share, refresh, and Back for free. A list filters by category and sort order.
Before — the filter lives in useState, so the view is private and ephemeral:
"use client";
function ProductList() {
const [category, setCategory] = useState("all");
const [sort, setSort] = useState("popular");
const items = query(category, sort);
return (
<>
<Select value={category} onChange={setCategory} options={CATEGORIES} />
<Select value={sort} onChange={setSort} options={SORTS} />
<Grid items={items} />
</>
);
}This works, but refresh resets it to all/popular, the URL never changes so the link can’t be shared, and Back doesn’t undo a filter. The state is navigational — it describes the view — so it belongs in the URL.
After — the URL is the source of truth; reads come from useSearchParams, writes are navigations:
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
function ProductList() {
const params = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const category = params.get("category") ?? "all";
const sort = params.get("sort") ?? "popular";
function update(key: string, value: string) {
const q = new URLSearchParams(params); // preserve the other keys
q.set(key, value);
router.push(`${pathname}?${q}`); // navigation → history + shareable
}
return (
<>
<Select value={category} onChange={(v) => update("category", v)} options={CATEGORIES} />
<Select value={sort} onChange={(v) => update("sort", v)} options={SORTS} />
<Grid items={query(category, sort)} />
</>
);
}There’s no more useState for these values and nothing to keep in sync — the component is a pure function of the search params. Now /products?category=shoes&sort=rating is a shareable, bookmarkable link; a refresh restores the exact filter; and Back walks the user through their previous selections. Crucially, the search text input (a high-frequency keystroke value) is not in this URL on every keypress — that stays in local state and is committed via router.replace on a debounce. Committed view state in the URL; transient typing in useState.
▸Why this works
Why is the URL the right home for shareable state, structurally? Because the URL is the only piece of application state the browser itself persists, serializes, and navigates. Put a filter there and you get three things you’d otherwise hand-build: refresh-survival (the address bar is reloaded with the page), linkability (the whole state is a copy-pasteable string), and history (each change is an entry, so Back/Forward just work). Replicating that with useState means wiring localStorage for persistence, a serializer for sharing, and a manual history stack for undo — three subsystems the platform already gives you for free when the state lives in the URL. It’s not just “a place to store state”; it’s free persistence and history.
▸Common mistake
The signature failure mode is putting transient or high-frequency state in the URL — pushing every keystroke of a search box, every hover, every drag pixel as a navigation. Two costs hit at once: the history stack fills with junk entries (typing “shoes” leaves five Back presses to escape one search), and every router.push re-renders the route subtree, so a fast input feels laggy. The rule is commit, don’t stream: the URL stores the committed value (on submit, or debounced via router.replace so no history entry is added), while the live keystrokes stay in local useState. The opposite mistake is just as common — leaving genuinely shareable state (the active tab, the page number) trapped in useState, where it can’t be linked or restored. Match storage to the state’s nature: committed and shareable → URL; transient and private → local state.
A search box should let users share a link to their results, but typing currently calls router.push(?q=...) on every keystroke — history is flooded and the input lags. What's the senior fix?
Some state isn’t really component state — it describes the view: filters, the active tab, the page number, the search query. That navigational, shareable state belongs in the URL, not useState, because the URL is the one place a view is addressable: it survives refresh, it’s linkable/bookmarkable, and each change is a history entry so Back/Forward work for free. With the App Router you read that state from useSearchParams (the URL is the single source of truth, with no mirrored useState to sync) and write it by building a new query string from the current params and calling router.push — a navigation, not a setState. The failure mode is overusing the pattern: pushing transient, high-frequency state (every keystroke, a hover, a drag) into the URL spams the history stack and re-renders the route on each change. The discipline is commit, don’t stream — keep the live value in local state and commit to the URL on debounce or submit (with router.replace for incremental commits). Match storage to nature: committed and shareable goes in the URL; transient and private stays in useState.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.