PRD: URL-Synced UI State Library
Owner: David Holmes
Date: 2026-05-24
ADR: ADR-016
Target release: v1.47.0
Problem
Every downstream app consuming the design system independently implements URL-backed UI state — tabs, filters, search, sort, view mode, panel open/close, wizard steps. This produces:
- Duplication — ~30 apps each have hand-rolled
useSearchParamsoruseHashParamwrappers, each with subtly different serialization, batching, and fallback behavior. - Inconsistency — boolean state is
"1"in one app,"true"in another,"yes"in a third. Set serialization uses commas in one app and pipes in another. Consumers can’t trust that a shared URL will parse correctly. - Fragility — most implementations don’t handle invalid/hand-edited URLs gracefully. A bad query string can crash a page or silently show wrong data.
- Missing batching — changing search + sort + filter simultaneously produces three history entries instead of one. Back button is broken.
- Router coupling — the best existing implementation (P3’s
useUrlState) is locked to TanStack Router. Apps on other routers can’t use it. - DS component gap — design-system components like Tabs, FilterBar, DataGrid, and SlideOutPanel accept
value+onChangebut offer no built-in path to URL-backing. Every app reinvents the wiring.
Why now
- The P3 app proved the pattern. Its
useUrlStatemodule has been stable for months and handles the hard parts: microtask batching, enum validation, set serialization, sort state compounds. - The design system has mature subpath export infrastructure (8 existing exports, Vite lib mode, tested build pipeline).
- The design system already has a working adapter injection pattern (
SlideOutPersistenceAdapter) that proves router-agnostic integration. - The design system already depends on Zod (for schema validation) and Zustand (for global state) — no new dependencies are needed.
What success looks like
- A developer in any downstream app can
import { useUrlString, useUrlEnum } from "@dmwd-io/design-system/url-state"and have URL-backed state working in under 5 minutes. - The only app-specific code is a ~30-line router adapter created once at the app root.
- DS components gain optional
urlKeyprops that wire URL-state automatically — zero boilerplate for the common case. - All 8 hook types (string, boolean, number, enum, set, sort, json, generic) share the same batching, fallback, and clean-URL behavior.
- P3 deletes its local
useUrlState.tsanduseHashParam.ts— the DS version is a strict superset.
Scope
In scope (this PRD)
| Deliverable | Description |
|---|---|
UrlStateAdapter interface | Three-method contract: getSearchParams, setSearchParams, subscribe |
UrlStateProvider | React context that holds the adapter instance |
createBrowserAdapter() | Zero-dependency fallback using window.location + popstate |
createTestAdapter() | In-memory adapter for unit testing |
| Microtask batch system | Queue param changes, flush once per microtask (ported from P3) |
useUrlState<T>(key, codec) | Generic hook — works with any codec |
useUrlString(key, fallback?) | String state |
useUrlBoolean(key, fallback?) | Boolean state ("1" / absent) |
useUrlNumber(key, fallback?) | Number state (NaN → fallback) |
useUrlEnum<T>(key, values, fallback) | Validated string union |
useUrlSet(key, fallback?) | Set<string> with add/remove/toggle/clear actions |
useUrlSort(prefix, defaultField, defaultDir?) | Compound sort (field + direction) |
useUrlJson<T>(key, schema, fallback) | Zod-validated JSON (base64-encoded in URL) |
createUrlCodec<T>() | Factory for custom codecs |
createUrlStateBundle() | Compound hook builder for screens with many params |
batchUrlUpdate() | Explicit batch function for programmatic multi-param updates |
| Subpath export wiring | Vite entry point + package.json exports for ./url-state |
| Unit test suite | Full coverage for all codecs, hooks, adapters, and batching |
| ADR-016 | Governing ADR (already written) |
Out of scope
- Router-specific adapter implementations (those live in consuming apps)
- SSR hydration adapter (Phase 2 — no DS app currently does SSR)
- URL path segment management (app router’s responsibility)
- Hash-fragment routing
- DS component integration props (
urlKeyon Tabs, FilterBar, etc.) — separate follow-up PR - Downstream app migration — separate per-app effort
Architecture
Layer diagram
┌─────────────────────────────────────────────────────┐
│ Consuming App │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Router Adapter (~30 lines) │ │
│ │ implements UrlStateAdapter │ │
│ └──────────┬───────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ UrlStateProvider adapter={...} │ │
│ │ (from @dmwd-io/design-system/url-state) │ │
│ └──────────┬───────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Hooks: useUrlString, useUrlEnum, etc. │ │
│ │ (from @dmwd-io/design-system/url-state) │ │
│ └──────────┬───────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ DS Components: Tabs, DataGrid, etc. │ │
│ │ (from @dmwd-io/design-system) │ │
│ │ Accept value + onChange — URL hooks provide │ │
│ │ the [value, setter] tuple. │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Key design decisions
1. Adapter injection, not router dependency.
The design system defines a UrlStateAdapter interface. The app creates the concrete adapter for its router. The DS never imports TanStack Router, React Router, Next.js, or any other framework.
2. Microtask batching for multi-setter correctness.
When multiple useUrlString/useUrlEnum/etc. setters fire in the same synchronous callback, each change is queued. A single queueMicrotask flush reads all pending changes and produces one setSearchParams call → one history entry. This is the proven pattern from P3’s useHashParam.
3. Clean URL semantics.
When a param’s value equals its fallback/default, the param is removed from the URL entirely. ?q=&sort=name&dir=asc → ?sort=name (if q default is "" and dir default is "asc").
4. Codecs are pure functions, not React hooks.
Each codec is { parse, serialize, defaultValue }. The hooks compose codecs with the adapter. Codecs can be tested in isolation without React.
5. Zod for JSON codec only. The JSON codec uses Zod for runtime validation because the data shape is complex. Primitive codecs (string, boolean, number, enum, set) don’t need Zod — they use simple type guards.
Implementation plan
Phase 1 — Core library (Week 1)
Goal: Ship a working @dmwd-io/design-system/url-state subpath export with all hooks, codecs, adapters, batching, and tests.
| Step | File(s) | Est. lines | Notes |
|---|---|---|---|
| 1.1 | src/url-state/types.ts | 30 | Shared types: UrlCodec<T>, UrlSetActions, UrlSortState |
| 1.2 | src/url-state/url-state-adapter.ts | 15 | UrlStateAdapter interface |
| 1.3 | src/url-state/browser-adapter.ts | 45 | createBrowserAdapter() — window.location + popstate |
| 1.4 | src/url-state/test-adapter.ts | 35 | createTestAdapter() — in-memory, synchronous, for tests |
| 1.5 | src/url-state/url-state-provider.tsx | 30 | React context + UrlStateProvider component |
| 1.6 | src/url-state/batch.ts | 55 | Microtask batcher — queue changes, flush via adapter |
| 1.7 | src/url-state/codecs/*.ts | 120 | All 7 built-in codecs + createUrlCodec factory |
| 1.8 | src/url-state/hooks/*.ts | 150 | All 8 hooks |
| 1.9 | src/url-state/create-url-state-bundle.ts | 40 | Compound hook factory |
| 1.10 | src/url-state/index.ts | 25 | Barrel export |
| 1.11 | Vite config + package.json | 10 | Add entry point + export map |
| 1.12 | src/url-state/__tests__/*.ts | 400 | Full test suite |
Total: ~955 lines (implementation + tests)
Phase 2 — DS component integration (Week 2, separate PR)
Add optional URL-state props to key components. Components remain fully functional without URL state — this is additive.
| Component | Integration |
|---|---|
| Tabs / NavTabs | Optional urlKey prop → internally uses useUrlEnum |
| SlideOutPanel | Optional urlKey prop → internally uses useUrlBoolean |
| FilterBar | Optional urlKey prop → internally uses useUrlSet |
| SearchInput | Optional urlKey prop → internally uses useUrlString |
| SortDropdown | Optional urlKeyPrefix prop → internally uses useUrlSort |
| PanelWizard | Optional urlKey prop → internally uses useUrlEnum / useUrlNumber |
Phase 3 — P3 migration (Week 3, P3 repo)
- Create
src/adapters/tanstack-url-state-adapter.tsin P3 - Add
UrlStateProviderto P3’s root - Replace all
@/lib/useUrlStateimports with@dmwd-io/design-system/url-state - Keep P3’s path-owned key logic as a separate local hook that calls DS primitives internally
- Delete P3’s
useUrlState.ts+useHashParam.ts
Phase 4 — Downstream rollout (Ongoing)
Each app team:
- Creates their router adapter (~30 lines)
- Adds
UrlStateProviderto the root - Migrates local URL-state code to DS hooks
- Deletes legacy helpers
Acceptance criteria
| # | Criterion | Verification |
|---|---|---|
| 1 | @dmwd-io/design-system/url-state exports all listed hooks, codecs, adapters, and utilities | pnpm build succeeds, TypeScript resolves the import |
| 2 | All hooks read and write URL params through the adapter — never through window.location directly | Code review: no window.location usage in hook files |
| 3 | Multiple setters in one synchronous callback produce exactly one history entry | Unit test: call 3 setters, assert adapter’s setSearchParams was called once |
| 4 | Invalid URL values (hand-edited, empty, wrong type) fall back to defaults without throwing | Unit tests: each codec tested with garbage input |
| 5 | Params at default value are removed from the URL | Unit test: set value to default, assert param is absent |
| 6 | createTestAdapter() works without window, document, or any DOM | Unit tests run in jsdom with no global side effects |
| 7 | createBrowserAdapter() works with window.location and fires on popstate | Unit test in jsdom environment |
| 8 | pnpm typecheck passes | CI gate |
| 9 | pnpm vitest run --project unit passes with all new tests | CI gate |
| 10 | pnpm build-storybook succeeds (no barrel import regressions) | CI gate |
| 11 | Bundle impact of /url-state subpath is under 5 KB gzipped | pnpm bundle:check (add threshold) |
| 12 | ADR-016 is committed and linked in the ADR index | File exists, index updated |
Risks
| Risk | Impact | Mitigation |
|---|---|---|
| Adapter abstraction too leaky for complex routers | Apps can’t use the library | The adapter interface is minimal (3 methods). Complex routing stays in the app. |
| Apps don’t adopt because migration seems hard | Fragmentation continues | Phase 3 proves migration on P3 first. Document the exact steps. |
| Codec serialization format changes are breaking | URL bookmarks break on upgrade | Codecs use the simplest possible format. Any format change is a major version bump. |
queueMicrotask batching has edge cases in concurrent React | Stale state on render | Tested with React 19 concurrent mode. The adapter reads live browser state, not stale React state. |
| SSR apps can’t use browser adapter | Hydration mismatch | SSR apps provide their own adapter that reads from the request. Browser adapter is client-only. Documented. |
Non-goals
- This is not a state management library. It does not replace Zustand for global app state.
- This is not a router. It does not manage URL paths, route matching, or navigation.
- This is not a persistence layer. It does not write to localStorage, cookies, or databases.
- This does not handle authentication tokens, session state, or any sensitive data in URLs.
Dependencies
- Zod (already a DS dependency) — used only in the
useUrlJsoncodec for schema validation - React 19 (already a DS peer dependency) — hooks use
useSyncExternalStorefor adapter subscription - No new dependencies
Open questions
| # | Question | Status |
|---|---|---|
| 1 | Should useUrlJson use base64 or URL-safe encoding? | Decided: base64 via btoa/atob — compact, handles special chars |
| 2 | Should the createUrlStateBundle return object-style or tuple-style per key? | Decided: object-style { [key]: [value, setter] } for readability |
| 3 | Should DS components auto-detect UrlStateProvider and use it for their own state? | Deferred to Phase 2. For now, components stay controlled — the app wires the hooks. |