---
title: "Carry Your State in the URL, Then Actually Carry It: The Dropped Query Param"
canonical: https://dxdev.com/blog/url-as-state-rethread-query-params-central-builder/
datePublished: 2026-02-27
---
I put one piece of selected state into a URL param and felt smart about it for exactly half a day. Then I spent a whole commit hunting down every internal link that forgot to carry it forward, because one missing `&div=` silently teleported the user back to "nothing selected." Not an error, not a redirect to a login page, just a quiet drop back to the root view as if they'd never picked anything at all.

URL-as-state is a great idea that comes with a tax: every navigation that should remain in the same scoped view has to preserve its context. Pay that cost once in a central function, or pay it at every call site and eventually miss one.

## The feature that needed the param

The app is a Classic ASP admin tool I've maintained for years. The new feature let an account admin drill into a sub-account from a dropdown: pick one, and the whole detail screen scopes to that sub-account's data instead of the parent account's.

The obvious place to keep "which sub-account is selected" is the URL: `?div=<ID>`. I wanted it there for three reasons that are the whole pitch for URL-as-state:

- It survives a reload. Refresh the page and you're still looking at the same sub-account.
- It's linkable. You can bookmark or send someone a deep link straight into a specific sub-account's view.
- The server already reads it. A server-side helper picks `?div=` off the query string and swaps the active account context to the selected sub-account before the page renders.

So far this is textbook. Selected state belongs in the URL the moment you want it to be bookmarkable and reload-safe. Component state held in memory dies on refresh; a query param does not.

## The tax nobody mentions

Here is the part the textbook leaves out. The instant your selection lives in the URL, any navigation that is meant to stay within that selection needs to preserve it. A detail link or post-save redirect may need the active parameter; a link to a different page may correctly discard it. Make that choice centrally, or the next page load can lose context by accident.

Miss it in one place and you get the worst kind of bug, the silent one. No exception, no stack trace, no log line. The user clicks something perfectly reasonable and the app forgets what they were doing.

I shipped exactly that, in two separate spots.

The detail-item header link built its target like this:

```js
'/manage/?p=detail&itemID=' + itemID
```

No `div`. So if you were filtered down to a sub-account and clicked an item inside it, you got yanked out of that sub-account's view and dumped back to the parent account screen. You picked a sub-account, did some work, clicked a related thing, and the app quietly threw away your selection.

The post-save reload had the same hole. After saving an item, the page did a short refresh to land you back on the right screen, and it built that reload URL without the param too:

```js
rfURL = '?p=detail&itemID=' + itemID;
// no div, so the 500ms refresh lands you back at the parent account root
```

Save your work, blink, and you're back at "no sub-account selected." Both bugs are the same bug: a navigation target that forgot the one param that holds the whole context together.

## The fix I actually shipped

The fix is uniform and completely unglamorous. Find every navigation target, append the param if it's present:

```js
url += (urlParams.div ? '&div=' + encodeURIComponent(urlParams.div) : '');
```

In the detail-item header, that exact line got tacked onto the link. In the post-save reload:

```js
if (urlParams.div) {
  rfURL += '&div=' + encodeURIComponent(urlParams.div);
}
```

And the sub-account switcher itself is just navigation that sets the param. Pick a different sub-account and it does:

```js
window.location.href = '?p=detail&div=' + encodeURIComponent(val);
```

That's the whole mechanism. The selected sub-account is a query parameter the page reads on load and preserves on navigations that remain in that scoped view. There's no store, no context provider, no framework. The URL is the source of truth, which makes the state durable but also makes accidental omission visible as context loss.

Note the `encodeURIComponent` on every one of those. Encoding preserves a parameter value when it contains URL-significant characters; it is correct construction hygiene, not a substitute for server-side validation and authorization of the selected identifier.

## Why hand-appending guarantees you miss one

I didn't find these by reading the code. I found them by using the feature and watching it drop me. The header-link bug surfaced when I clicked an item and lost my sub-account. The reload bug surfaced when I saved and ended up at the root. Each one was a separate "huh, where did my selection go," and each one was a one-line fix in a different file.

That's the tell. When the fix for a class of bug is "the same line, pasted into N different places," the real bug is that there are N places at all. Hand-appending `&div=` at each call site is a process that depends on me remembering, every single time I write a link, that this particular param needs threading. I will not remember every time. Nobody does. The codebase had links written before the param existed and links written after, and the ones written before had no reason to think about it.

The failure mode makes it worse. A dropped param doesn't break loudly. It degrades silently into "context lost," which is precisely the thing users report as "it randomly kicks me out of the event" or "it keeps logging me out of the thing I was working on." You don't get a bug report that says "the detail link is missing a query param." You get a vague complaint about the app losing its place, and you go spelunking.

## The thing I should have built first

There should have been one function that produces URLs for this page, and it should re-thread the active params automatically. Something with the shape of:

```js
function pageUrl(base, extraParams) {
  var params = $.extend({}, carryForward(), extraParams);
  // carryForward() reads the active state-bearing params off the
  // current URL (div, and anything else that scopes the page)
  return base + '?' + serialize(params);
}
```

Every navigation that remains in the scoped page goes through `pageUrl`. The detail-item link becomes `pageUrl('/manage/', {p: 'detail', itemID: id})`, and the `div` rides along because `carryForward` knows it is part of that page's identity. There is now exactly one place to decide which state-bearing parameters continue and which should be discarded for a new destination.

I didn't build that first. I built it after the feature shipped and after I'd fixed the same drop twice, which is the usual order and a slightly embarrassing one.

## Related

- [Routing Logic That Does Work on the Way In: Redirect, Auto-Select, or Prompt](/blog/resolve-selection-before-expensive-work-redirect-autopick-prompt/): pre-resolving state before committing to a navigation target
