The URL Is a State Manager, and Filters Do Not Belong in useState
A visitor filters a list, goes to page three, refreshes, and everything resets. The cause is the same in plain JavaScript, React, Vue, Angular and Svelte. The state lives in memory when it should live in the URL. Here is why, how to do it in each, side by side, and where it stops being a good idea.
- react
- vue
- angular
- svelte
- nextjs
- state
- typescript
## A bug you have seen before
You are on a product list. You filter by category, sort by price, and click through to page three. Then one of these happens:
- You refresh, and you are back on page one with no filter.
- You copy the address to a colleague, and they see the unfiltered list.
- You open a product, press back, and the list has forgotten everything.
- You open the same page in a second tab to compare, and it starts from scratch.
None of these is a bug in the usual sense. The code does what it was written to do. The filter, the sort order and the page number were stored in component state, and component state lives in the memory of one tab for as long as that component is mounted. Refresh, navigate away, or open a new tab, and it is gone.
This is the same in every framework, and with no framework at all. In React it is useState. In Vue it is ref. In Angular it is a signal. In Svelte it is a $state rune. In plain JavaScript it is a variable. The names differ and the problem is identical.
## Where state can live
The fix starts with a question that is easy to skip: where should this piece of state live? There are more options than "in the component".
| Where | Survives a reload | Can be shared as a link | Works with back and forward | Separate per tab | Good for |
|---|---|---|---|---|---|
| Component state | No | No | No | Yes | A menu being open, a field being typed in |
| A global store | No | No | No | Yes | State many components share for one session |
| Local storage | Yes | No | No | No, it is shared | Preferences such as a theme or a dismissed banner |
| The URL | Yes | Yes | Yes | Yes | What the visitor is looking at: filters, sort, page |
| The server | Yes | To anyone with access | Not applicable | No, it is shared | Data that belongs to the user or the business |
By "component state" I mean useState or useReducer in React, ref or reactive in Vue, a signal in Angular and $state in Svelte. By "a global store" I mean Redux, Zustand, Pinia, NgRx and their relatives. A global store fixes one thing that component state cannot do, which is sharing between distant components. It does not fix any of the four problems in the list above, because it is still memory in one tab.
Only one row has "yes" all the way across the first three columns.
## What the URL gives you for free
When state is in the URL, the browser does work you would otherwise have to write.
- Reload restores it. The address is the first thing the browser has on a reload.
- Links carry it. Anyone who opens the link sees what you saw.
- Back and forward step through it. The browser's history is an undo stack you did not have to build.
- Bookmarks keep it. A visitor can save a filtered view and come back to it next week.
- Each tab has its own. Two tabs can show two different filters without fighting.
- The server can see it. With server rendering, the first response can already be filtered and sorted, with no flash of the default view.
- It is inspectable. When a user reports a problem, the address they paste tells you exactly what they were looking at.
The mental model that makes this click: the URL is the state, written down. The page is a function of it. Controls do not hold a value. They read it from the URL and write changes back to it.
## The usual version, and what is wrong with it
Here is a list with a category filter and pagination, written the way most of us write it first. Pick your framework. The tabs stay in step for the rest of the post, so you only have to choose once.
let category = 'all';
let page = 1;
function render() {
const visible = paginate(filterBy(products, category), page);
list.replaceChildren(...visible.map(productCard));
}
picker.addEventListener('change', () => {
category = picker.value;
page = 1;
render();
});
pager.addEventListener('click', (event) => {
page = Number((event.target as HTMLElement).dataset.page);
render();
});Every one of these is clean and works. Every one of them has all four problems from the top of this post, because in each the two values are held in memory. The address bar says /products whatever the visitor is looking at.
## Moving it to the URL by hand
No library is required in any of them. The pattern does not change from tab to tab: read the value from the URL, and write a change by navigating to a new URL.
This is what every framework is doing underneath.
function readPage(): number {
const params = new URLSearchParams(window.location.search);
return Number(params.get('page')) || 1;
}
function writePage(page: number) {
const params = new URLSearchParams(window.location.search);
// The default is left out, so page one keeps a clean address.
if (page === 1) params.delete('page');
else params.set('page', String(page));
const query = params.toString();
history.pushState(null, '', query ? `?${query}` : window.location.pathname);
render();
}
// Back and forward change the URL without loading a page. Listen for it.
window.addEventListener('popstate', render);render now calls readPage() where it used to read the page variable. There is no variable any more.
Compare the tabs and the shape is identical. Parse a string from the query, fall back to a default when it is missing, leave the default out when writing, keep the other parameters, and navigate. Only the names of the router functions differ.
Written out like this, the repeated work is also obvious. Do it for five parameters and you have written a small library. That is the reason to reach for one.
## The same thing with less code
Each ecosystem has a shorter way to say the same thing.
There is nothing to install. The two functions from the last section are the library. If you have several parameters, turn them into one small helper that takes a name, a default and a parser, and you have written the core of every library in the other tabs.
function queryParam<T>(name: string, fallback: T, parse: (raw: string) => T) {
return {
read(): T {
const raw = new URLSearchParams(window.location.search).get(name);
return raw === null ? fallback : parse(raw);
},
write(value: T) {
const params = new URLSearchParams(window.location.search);
if (value === fallback) params.delete(name);
else params.set(name, String(value));
const query = params.toString();
history.pushState(null, '', query ? `?${query}` : window.location.pathname);
}
};
}
const page = queryParam('page', 1, Number);Whichever tab you are on, try the result on a real page. Open the blog index, pick two topics, go to page two, and copy the address. That link opens the same view for anyone.
## Four details that apply in every framework
Getting a value into the URL is the easy half. These are the parts that make it hold up, and none of them depends on the library or the framework.
### The URL is user input
This is the one that is easiest to forget. With useState, your own code is the only thing that ever sets the value, so it is always valid. Once the value is in the address bar, anyone can type anything. A link can be a year old. A category can have been renamed.
So what comes out of the URL is not yet trustworthy. For the tags, the archive checks each one against the tags that actually exist.
const tags = [...new Set(posts.flatMap((post) => post.tags))].sort((a, b) => a.localeCompare(b));
// A topic no post carries is a stale or mistyped link, not a filter, and a
// topic named twice is still one topic.
const selected = [...new Set(tagsParam)].filter((tag) => tags.includes(tag));A visitor who arrives at ?tags=nonsense sees the normal archive, not an empty list under a heading that says "Posts on nonsense". For the page, the number is clamped to the range that exists.
const pageCount = Math.max(1, Math.ceil(matching.length / PAGE_SIZE));
const page = Math.min(Math.max(pageParam, 1), pageCount);?page=999 shows the last page. ?page=-3 shows the first. Notice the naming: tagsParam and pageParam are what the URL said, and selected and page are what the component decided to believe. Everything after those lines uses the second pair.
### State that depends on other state
Choosing a topic changes how many results there are. If you were on page three of everything and pick a topic with four posts, page three no longer exists. So changing the filter also resets the page.
const toggleTag = (tag: string) => {
setTags(selected.includes(tag) ? selected.filter((name) => name !== tag) : [...selected, tag]);
setPage(1);
};This is true with useState as well. It matters more here because a broken combination is now something a visitor can bookmark.
### Push or replace
Every change to the URL either adds a history entry or overwrites the current one, and you have to choose.
| Choice | Back button | Use it for |
|---|---|---|
| Push | Undoes the change | Deliberate steps: opening a tab, going to another page |
| Replace | Leaves the page | Rapid or minor changes: typing in a search box, a slider |
If typing "shoes" in a search box pushes five entries, the visitor has to press back five times to leave. If changing page replaces, back skips the list entirely. Neither is wrong everywhere, so decide per parameter. Every router offers both.
| Where | Push | Replace |
|---|---|---|
| Plain JavaScript | history.pushState | history.replaceState |
| React with Next.js | router.push | router.replace |
| React with nuqs | The history: 'push' option | The default |
| Vue Router | router.push | router.replace |
| Angular | router.navigate | router.navigate with replaceUrl: true |
| SvelteKit | goto | goto with replaceState: true |
### Keep the address short and clean
A URL is something people read, paste into chat and say out loud.
- Leave defaults out.
/blogis better than/blog?page=1&sort=newest&tags=. - Throttle free text. Update the address a few times a second at most, not on every keystroke. nuqs has a
throttleMsoption for this. - Use short, stable names.
?q=and?page=will outlive a redesign. A parameter name is a public interface once people have bookmarked it. - Never put secrets in it. URLs are written to server logs, saved in browser history, and sent to other sites in the referrer header. Tokens, emails and anything personal do not belong there.
## What it changes about rendering
Moving state to the URL has one consequence that component state never had: the server may or may not know the value when it renders.
In a server-rendered app this is a benefit. The request includes the query string, so the first response can already be filtered. There is nothing to "restore" on the client.
In a statically generated app it is a constraint. The page is built before any visitor exists, so there is no query string to read. In the Next.js App Router, a component that reads search params must sit inside a Suspense boundary, and the build fails without one. Where you put it matters, because React gives up on static rendering at the nearest boundary. This blog wraps only the part that reads the URL.
<LatestPosts posts={latest} />
<Suspense fallback={<PendingList label="Loading the archive" count={3} />}>
<PostArchive posts={posts} />
</Suspense>The heading and the row of latest posts are in the static HTML that a crawler or a slow connection sees first. Only the archive waits for the client.
One more thing that tripped me up. The archive rows animate in, and my first instinct was to reveal them as they scroll into view. That breaks when the URL changes the list under you: clicking "Older" creates new rows inside a list you are already looking at, and an in-view trigger can leave them invisible. The list is keyed by the view and animated directly, so a new filter or page mounts a new list and the entrance plays at once.
<motion.ol
key={`${selected.join(',')}:${page}`}
initial="hidden"
animate="visible"
>## When component state is the right answer
None of this means useState and ref are wrong. They are right for most state. The URL is for one particular kind.
A good test: if someone sent you this link, would you be annoyed that it did not restore this? If yes, it belongs in the URL. If you would not even notice, keep it in the component.
| State | Where it belongs | Why |
|---|---|---|
| Filters, sort order, search term, page number | The URL | It describes what is on screen |
| The selected tab or the open record | The URL | People link to "the billing tab" |
| A date range on a report | The URL | The first thing anyone does is share the report |
| A dropdown or tooltip being open | Component state | It is a moment, not a view |
| Text typed into a form but not submitted | Component state | Sharing half a sentence helps nobody |
| Hover, focus, drag position | Component state | It changes far too often and means nothing later |
| Theme, language, a dismissed banner | Local storage or cookies | It is about the person, not the page |
| The signed-in user, a cart, saved items | The server | It must follow the user across devices |
| A password, a token, personal details | Never the URL | URLs are logged, stored and shared |
Two limits are worth knowing even for state that passes the test.
- Size. A URL is not a database. Browsers and servers cap its length, and long before that it becomes unreadable. A handful of short values is fine. A whole form or a list of fifty selected ids is not. Store that on the server and put its id in the URL.
- Frequency. Writing to the URL on every frame of a drag or every keystroke is wasteful and floods the history. Keep the fast-changing value in component state and write to the URL when the interaction settles.
## Summary
- Component state lives in one tab's memory. Reload, share, back and bookmark all lose it, in every framework.
- A global store does not fix that. It is still memory in one tab.
- The URL is the one place that survives a reload, can be shared, and works with the back button.
- The pattern is the same in plain JavaScript, React, Vue, Angular and Svelte: read from the URL, and write by navigating. A library or a built-in router feature removes the repetition.
- Treat what comes out of the URL as user input. Validate it and clamp it.
- Reset dependent state, such as the page, when the thing it depends on changes.
- Choose push or replace per parameter, leave defaults out, and never put secrets in a URL.
- Keep moments in the component, preferences in storage, and the user's data on the server.
Published on September 29, 2026
19 min read
Found an Issue!
Find an issue with this post? Think you could clarify, update or add something? All my posts are available to edit on Github. Any fix, little or small, is appreciated!
Edit on GitHubLast updated on
