State Architecture
URL as Source of Truth for Filters
Reading level
The filter that disappears on refresh
User applies filters to a product list — category, price range, sort order. They find what they want and copy the URL to share with a colleague. The colleague opens the link. The filters are gone — the page shows unfiltered results. The user refreshes — the filters reset. This is the cost of keeping filter state in component state instead of the URL.
Filter, sort, pagination, and search state is "UI state that users expect to be bookmarkable and shareable." Storing it in component state is a UX failure — it's ephemeral, doesn't survive refresh, and can't be linked to. The URL querystring is the natural, persistent store for this state.
URL-as-state is also SEO state for e-commerce and content sites. Google indexes URL-parameterized filter pages. Products filtered by category at /products?category=shoes&sort=price can rank. Component state at the same URL cannot. The architecture decision has both UX and business implications.
Reading and writing URL params
// Read from URL
const params = new URLSearchParams(window.location.search);
const category = params.get('category') || 'all';
const sort = params.get('sort') || 'popular';
// Write to URL without full reload
function setFilter(key, value) {
const url = new URL(window.location.href);
url.searchParams.set(key, value);
history.pushState({}, '', url); // updates URL bar, no reload
}
// React to back/forward navigation
window.addEventListener('popstate', () => {
const params = new URLSearchParams(window.location.search);
updateFiltersFromParams(params);
});
In React with React Router v6:
import { useSearchParams } from 'react-router-dom';
function ProductFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') || 'all';
return (
<select
value={category}
onChange={(e) => setSearchParams(prev => {
prev.set('category', e.target.value);
return prev;
})}
>
{/* options */}
</select>
);
}
useSearchParams is the React Router native hook for URL query params — it handles the synchronization between URL and state automatically.
State architecture decision matrix for UI state:
- URL query params — filter, sort, search, pagination (shareable + bookmarkable)
- URL path — entity identity (/products/123)
- Component state — ephemeral UI (open/closed, hover, focus)
- Global state (Redux, Zustand) — cross-component derived state, server cache
- localStorage — user preferences that persist across sessions (theme, density)
Putting filter state in global store (Redux) when it should be in the URL is a common over-engineering that creates the same UX bugs (non-shareable) while adding more complexity.
The filtered view nobody else could see
Sarah spends 20 minutes applying 4 filters to a customer data table — status: "Active", region: "APAC", plan: "Pro", joined: "Last 30 days." She finds exactly the cohort she was looking for, 47 accounts, and wants her colleague James to verify the numbers. She copies the URL and pastes it in Slack. James opens it. He sees 8,000 unfiltered accounts. No filters applied. He sends back "I'm not seeing 47." Sarah is confused. She refreshes her own tab. Her 47 accounts are gone too. The filters were only in React state — they never touched the URL.
The implementation used useState for all four filter values. No URL sync, no serialization, no history.pushState. When Sarah copied the URL, she copied /customers — a URL that carries no filter information whatsoever. When James opened it, the component initialized with default state. When Sarah refreshed, the component re-mounted with default state. Four filters, four state values, zero persistence.
This bug has a hidden business cost: the filters Sarah applied were a meaningful analysis — 47 Pro accounts in APAC who joined in the last 30 days, the team's highest-priority expansion targets. Every time someone needs to reproduce this view, they spend 5 minutes re-applying 4 filters. Multiply by team size and frequency, and the lost time is significant. The feature appears to work, so it never gets prioritized for a fix. URL-as-state is a correctness requirement, not a polish feature.
Every filter change writes to the URL
After the fix, applying filters updates the URL bar in real time: /customers?status=active®ion=apac&plan=pro&joined=30d. Sarah applies her 4 filters, copies that URL, and pastes it to James. He opens it — the exact same 47 accounts, filters pre-applied. She refreshes — filters persist. The URL is now the source of truth. The data lives in the address bar, not React memory.
The migration was mechanical. Each useState filter was replaced with a read from URLSearchParams and writes through history.pushState. A popstate listener keeps the UI in sync when the user navigates back/forward. In React Router v6, useSearchParams does this in one hook:
// Before (component state — not shareable)
const [status, setStatus] = useState('active');
const [region, setRegion] = useState('all');
// After (URL state — shareable, bookmarkable, refresh-safe)
const [searchParams, setSearchParams] = useSearchParams();
const status = searchParams.get('status') ?? 'active';
const region = searchParams.get('region') ?? 'all';
const setFilter = (key, value) =>
setSearchParams(prev => { prev.set(key, value); return prev; });
Two additional wins came from the migration: (1) Google started indexing the filtered URLs for publicly visible product catalog pages — /products?category=shoes&sort=price-asc became a rankable page. (2) Support tickets with "can you look at this?" started including a URL with full context instead of written descriptions. The team added a Playwright test that navigates to a hardcoded filter URL and asserts the correct filtered result count — a test that was impossible before the fix.
Pattern at a glance
Annotated example: filter state in useState vs URL query params
❌ FILTER STATE IN COMPONENT STATE
// State lives in React memory only
const [status, setStatus] = useState('active');
const [sort, setSort] = useState('date');
const [page, setPage] = useState(1);
// URL stays at: /customers
// (no querystring — nothing to share or bookmark)
// On refresh: state resets to defaults
// On share: colleague sees unfiltered view
// On back: browser back doesn't restore filters
URL: /customers — unshareable, lost on refresh
✅ FILTER STATE IN URL PARAMS
// React Router v6
const [searchParams, setSearchParams] = useSearchParams();
const status = searchParams.get('status') ?? 'active';
const sort = searchParams.get('sort') ?? 'date';
const page = Number(searchParams.get('page') ?? 1);
// URL becomes: /customers?status=active&sort=date&page=1
// On refresh: URL parsed, same filters applied
// On share: colleague sees identical filtered view
// On back: browser back restores previous filter state
URL: /customers?status=active&sort=date — shareable
Try it: filter in state vs URL
Apply filters in "Broken" mode, then reload the page — filters are gone. Switch to "Fixed" and apply filters — the URL updates. Reload — filters persist.
Notice the URL bar in fixed mode as you click filters. Each filter change updates the querystring. Browser back/forward restores the previous filter state.
Copy the filtered URL from the fixed demo and open in a new tab — the filters are applied immediately on page load. That's the sharing and SEO benefit in action.
Showing: Fixed — URL state
Implementing URL state: native API to framework patterns
No framework needed for the basics:
// Read: parse current URL querystring
const params = new URLSearchParams(window.location.search);
const status = params.get('status') ?? 'all';
// Write: update URL without reloading the page
function setFilter(key, value) {
const url = new URL(window.location.href);
url.searchParams.set(key, value);
history.pushState({}, '', url);
renderTable(); // re-render with new filter
}
// Sync UI when user hits back/forward
window.addEventListener('popstate', () => {
const params = new URLSearchParams(window.location.search);
applyFiltersFromParams(params);
renderTable();
});
Key pitfall: forgetting the popstate listener. Without it, browser back/forward changes the URL but the UI doesn't update to match.
React Router v6 and Next.js patterns:
// React Router v6 — useSearchParams
import { useSearchParams } from 'react-router-dom';
function Filters() {
const [params, setParams] = useSearchParams();
const status = params.get('status') ?? 'all';
// Merge update (preserve other params)
const update = (key, val) =>
setParams(p => { p.set(key, val); return p; });
return <select value={status} onChange={e => update('status', e.target.value)}>...</select>;
}
// Next.js App Router — useRouter + useSearchParams
import { useRouter, useSearchParams } from 'next/navigation';
function Filters() {
const router = useRouter();
const params = useSearchParams();
const update = (key, val) => {
const next = new URLSearchParams(params.toString());
next.set(key, val);
router.push(`?${next.toString()}`);
};
}
Implementation notes:
- Always merge into existing params (
prev.set(key, val)) — never replace the whole querystring, or other filters get wiped - Use
replaceinstead ofpushfor ephemeral state like page number — keeps history clean - Serialize arrays as repeated params:
tag=a&tag=b, read withparams.getAll('tag') - Skip URL sync for truly ephemeral UI: dropdown open/closed, hover state, tooltip visibility — these don't need to survive refresh
At design system / team scale, consider a typed URL state hook:
// nuqs (Next.js) or custom: typed search param hooks
import { useQueryState } from 'nuqs';
function Filters() {
const [status, setStatus] = useQueryState('status', {
defaultValue: 'all',
parse: (v) => ['all', 'active', 'churned'].includes(v) ? v : 'all',
});
// Fully typed, validated, synced to URL automatically
}
State architecture matrix — where each type of UI state belongs:
- URL path (
/products/123) — entity identity, navigable - URL query params — filter, sort, search, page (shareable + bookmarkable)
- Component state — open/closed, hover, focus (ephemeral, lives and dies with the component)
- Global store (Redux/Zustand) — cross-component derived state, optimistic updates, server cache
- localStorage — user preferences persisted across sessions (theme, density, column order)
Red flag: filter state in Redux or Zustand when it should be in the URL. This adds store complexity while delivering the same UX bugs — filters still aren't shareable unless you also write a URL sync layer on top. Pick the right store for the state type from the start.
References
Remember
Key takeaways
-
Filter, sort, and search state belongs in the URL querystring — not component state. It's bookmarkable, shareable, and survives refresh.URLSearchParams + history.pushState is the native API — no library needed for basic URL state management.URL-parameterized filter pages are indexed by search engines. Component-state filters are not. This is a business decision, not just a UX preference.
-
Listen to the popstate event to sync UI when user clicks back/forward — URL state changes on navigation, not just on user action.React Router's useSearchParams handles the two-way sync automatically — state reads from URL, writes back to URL, syncs on navigation.Don't put filter state in Redux or Zustand when URL is the right store — over-engineering the state layer creates the same UX bugs with added complexity.
Keep going
Finish this takeaway, then continue the track — Casey saved your spot locally.
Sign in with email to sync progress across devices (beta).
Inside the Casebook
New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.
No spam. Unsubscribe anytime. Emails sent via Buttondown.
RSS feed