Skip to content

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:

  1. Duplication — ~30 apps each have hand-rolled useSearchParams or useHashParam wrappers, each with subtly different serialization, batching, and fallback behavior.
  2. 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.
  3. Fragility — most implementations don’t handle invalid/hand-edited URLs gracefully. A bad query string can crash a page or silently show wrong data.
  4. Missing batching — changing search + sort + filter simultaneously produces three history entries instead of one. Back button is broken.
  5. Router coupling — the best existing implementation (P3’s useUrlState) is locked to TanStack Router. Apps on other routers can’t use it.
  6. DS component gap — design-system components like Tabs, FilterBar, DataGrid, and SlideOutPanel accept value + onChange but offer no built-in path to URL-backing. Every app reinvents the wiring.

Why now

  • The P3 app proved the pattern. Its useUrlState module 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

  1. 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.
  2. The only app-specific code is a ~30-line router adapter created once at the app root.
  3. DS components gain optional urlKey props that wire URL-state automatically — zero boilerplate for the common case.
  4. All 8 hook types (string, boolean, number, enum, set, sort, json, generic) share the same batching, fallback, and clean-URL behavior.
  5. P3 deletes its local useUrlState.ts and useHashParam.ts — the DS version is a strict superset.

Scope

In scope (this PRD)

DeliverableDescription
UrlStateAdapter interfaceThree-method contract: getSearchParams, setSearchParams, subscribe
UrlStateProviderReact context that holds the adapter instance
createBrowserAdapter()Zero-dependency fallback using window.location + popstate
createTestAdapter()In-memory adapter for unit testing
Microtask batch systemQueue 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 wiringVite entry point + package.json exports for ./url-state
Unit test suiteFull coverage for all codecs, hooks, adapters, and batching
ADR-016Governing 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 (urlKey on 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.

StepFile(s)Est. linesNotes
1.1src/url-state/types.ts30Shared types: UrlCodec<T>, UrlSetActions, UrlSortState
1.2src/url-state/url-state-adapter.ts15UrlStateAdapter interface
1.3src/url-state/browser-adapter.ts45createBrowserAdapter()window.location + popstate
1.4src/url-state/test-adapter.ts35createTestAdapter() — in-memory, synchronous, for tests
1.5src/url-state/url-state-provider.tsx30React context + UrlStateProvider component
1.6src/url-state/batch.ts55Microtask batcher — queue changes, flush via adapter
1.7src/url-state/codecs/*.ts120All 7 built-in codecs + createUrlCodec factory
1.8src/url-state/hooks/*.ts150All 8 hooks
1.9src/url-state/create-url-state-bundle.ts40Compound hook factory
1.10src/url-state/index.ts25Barrel export
1.11Vite config + package.json10Add entry point + export map
1.12src/url-state/__tests__/*.ts400Full 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.

ComponentIntegration
Tabs / NavTabsOptional urlKey prop → internally uses useUrlEnum
SlideOutPanelOptional urlKey prop → internally uses useUrlBoolean
FilterBarOptional urlKey prop → internally uses useUrlSet
SearchInputOptional urlKey prop → internally uses useUrlString
SortDropdownOptional urlKeyPrefix prop → internally uses useUrlSort
PanelWizardOptional urlKey prop → internally uses useUrlEnum / useUrlNumber

Phase 3 — P3 migration (Week 3, P3 repo)

  1. Create src/adapters/tanstack-url-state-adapter.ts in P3
  2. Add UrlStateProvider to P3’s root
  3. Replace all @/lib/useUrlState imports with @dmwd-io/design-system/url-state
  4. Keep P3’s path-owned key logic as a separate local hook that calls DS primitives internally
  5. Delete P3’s useUrlState.ts + useHashParam.ts

Phase 4 — Downstream rollout (Ongoing)

Each app team:

  1. Creates their router adapter (~30 lines)
  2. Adds UrlStateProvider to the root
  3. Migrates local URL-state code to DS hooks
  4. Deletes legacy helpers

Acceptance criteria

#CriterionVerification
1@dmwd-io/design-system/url-state exports all listed hooks, codecs, adapters, and utilitiespnpm build succeeds, TypeScript resolves the import
2All hooks read and write URL params through the adapter — never through window.location directlyCode review: no window.location usage in hook files
3Multiple setters in one synchronous callback produce exactly one history entryUnit test: call 3 setters, assert adapter’s setSearchParams was called once
4Invalid URL values (hand-edited, empty, wrong type) fall back to defaults without throwingUnit tests: each codec tested with garbage input
5Params at default value are removed from the URLUnit test: set value to default, assert param is absent
6createTestAdapter() works without window, document, or any DOMUnit tests run in jsdom with no global side effects
7createBrowserAdapter() works with window.location and fires on popstateUnit test in jsdom environment
8pnpm typecheck passesCI gate
9pnpm vitest run --project unit passes with all new testsCI gate
10pnpm build-storybook succeeds (no barrel import regressions)CI gate
11Bundle impact of /url-state subpath is under 5 KB gzippedpnpm bundle:check (add threshold)
12ADR-016 is committed and linked in the ADR indexFile exists, index updated

Risks

RiskImpactMitigation
Adapter abstraction too leaky for complex routersApps can’t use the libraryThe adapter interface is minimal (3 methods). Complex routing stays in the app.
Apps don’t adopt because migration seems hardFragmentation continuesPhase 3 proves migration on P3 first. Document the exact steps.
Codec serialization format changes are breakingURL bookmarks break on upgradeCodecs use the simplest possible format. Any format change is a major version bump.
queueMicrotask batching has edge cases in concurrent ReactStale state on renderTested with React 19 concurrent mode. The adapter reads live browser state, not stale React state.
SSR apps can’t use browser adapterHydration mismatchSSR 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 useUrlJson codec for schema validation
  • React 19 (already a DS peer dependency) — hooks use useSyncExternalStore for adapter subscription
  • No new dependencies

Open questions

#QuestionStatus
1Should useUrlJson use base64 or URL-safe encoding?Decided: base64 via btoa/atob — compact, handles special chars
2Should the createUrlStateBundle return object-style or tuple-style per key?Decided: object-style { [key]: [value, setter] } for readability
3Should 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.