We put one piece of selected state into a URL param, and it looked like a good decision for exactly half a day. Then we spent a whole commit hunting down every internal link that forgot to carry it forward, because one missing &div= sent the user back to “nothing selected.” There was no error and no redirect to a login page. The user just landed on the root view as if they had never picked anything.

URL-as-state comes with a tax. Every navigation that should stay in the same scoped view has to preserve its context. You can pay that 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 legacy admin tool we have 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>. We wanted it there for three reasons, which 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 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.

Selected state belongs in the URL the moment you want it bookmarkable and reload-safe. Component state held in memory dies on refresh, and a query param does not.

Where the selection leaked

Once the selection lives in the URL, any navigation meant to stay inside it has to preserve it. A detail link or a post-save redirect may need the active parameter. A link to a different page may correctly discard it. If nothing makes that choice in one place, the next page load can lose context by accident.

Miss it once and you get a silent bug. No exception, no stack trace, no log line. The user clicks something perfectly reasonable and the app forgets what they were doing.

We shipped that in two separate spots.

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

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

No div. If you were filtered down to a sub-account and clicked an item inside it, you got pulled out of that sub-account’s view and dropped on the parent account screen.

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:

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 were the same bug. A navigation target forgot the one param that holds the context together.

The fix we shipped first

Find every navigation target and append the param if it’s present:

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:

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

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

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

That is the whole mechanism. The selected sub-account is a query parameter the page reads on load and preserves on navigations that stay in that scoped view. There is no store, no context provider, no framework. The URL is the source of truth, so the state is durable, and an accidental omission shows up as lost context.

Every one of those calls uses encodeURIComponent. It keeps a value intact when it contains URL-significant characters, which makes it construction hygiene, not a substitute for server-side validation and authorization of the selected identifier.

Why hand-appending guarantees you miss one

We did not find these by reading the code. We found them by using the feature and watching it drop the selection. The header-link bug surfaced when we clicked an item and the sub-account was gone. The reload bug surfaced when we saved and ended up at the root. Each was its own “where did the selection go,” and each was a one-line fix in a different file.

That is the tell. When the fix for a class of bug is the same line pasted into N different places, the trouble is that there are N places at all. Hand-appending &div= at each call site only works if whoever writes a link remembers to thread this particular param every single time. The codebase had links written before the param existed and links written after, and the older ones had no reason to think about it.

The failure mode makes it worse. A dropped param degrades into “context lost,” which users describe as “it randomly kicks you out of the event” or “it keeps logging you out of the thing you were working on.” Nobody files a report saying the detail link is missing a query param. You get a vague complaint about the app losing its place, and then you go spelunking.

What we built after the second drop

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

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 stays 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. That leaves exactly one place to decide which state-bearing parameters continue and which get discarded for a new destination.

We did not build it first. We built it after the feature shipped and after we had fixed the same drop twice, which is the usual order and a slightly embarrassing one.

What to check on your own screens

If a screen keeps its selection in the URL, find where the rule lives for which links carry that selection forward. If the answer is “each link author remembers,” you have the same bug waiting in a file you haven’t clicked through yet. A dropped parameter produces no error, only users who lose their place, so walk the navigation paths yourself with the feature in use, because no bug report will point you at the missing param.