Summary
Add a useQueryState-style API that bidirectionally syncs comwit model fields with URL query parameters, SSR-friendly and Next.js App Router compatible.
Motivation
URL-driven state is essential for shareable links, browser back/forward, and SSR hydration. Currently comwit handles client-side state well but has no built-in way to persist state to the URL.
Sync Direction
- Initial load (URL → state): On SSR or first client render, parse URL search params and hydrate the model field.
- Subsequent updates (state → URL): When the model field changes via actions, push the new value to the URL query string.
- External navigation (URL → state): Listen for
popstate / router events and update state accordingly.
Proposed API (Rough Sketch)
Model-level declaration
import { model, queryParam } from 'comwit'
export const search = model<SearchState>({
keyword: queryParam<string>({ key: 'q', parse: String, serialize: String, defaultValue: '' }),
page: queryParam<number>({ key: 'page', parse: Number, serialize: String, defaultValue: 1 }),
sort: queryParam<string>({ key: 'sort', parse: String, serialize: String, defaultValue: 'latest' }),
})
Provider-level config
<ComwitProvider
context={context}
queryString={{
// shallow by default — use History API directly, don't trigger Next.js server re-render
shallow: true,
// throttle URL writes (browsers rate-limit pushState, especially Safari)
throttleMs: 50,
// remove param from URL when value equals default
clearOnDefault: true,
}}
>
SSR support (Next.js App Router)
Server components can't use hooks. Need a server-side utility to parse searchParams and pass to client:
// app/search/page.tsx (server component)
import { parseSearchParams } from 'comwit/server'
import { search } from '@/state/search/model'
export default async function SearchPage({ searchParams }) {
const initial = parseSearchParams(search, await searchParams)
return <SearchClient initial={initial} />
}
// client component — silent() hydrates without re-render
function SearchClient({ initial }) {
const { actions } = useSearch(s => ({ actions: s.actions }))
actions.init(initial)
// ...
}
Key Design Considerations
- Batching: Multiple
queryParam fields changing in the same tick should produce a single URL update (queue + flush pattern, like nuqs's internal Map queue)
- Shallow-first: Default to
history.replaceState / history.pushState directly, bypassing Next.js router to avoid server re-renders. Opt-in shallow: false for cases that need server re-render.
- Optimistic UI: Update React state immediately, defer URL write (decouple render from URL flush)
- History mode: Support both
push (adds browser history entry) and replace (default, no history entry) per field or per update
- Parser pattern:
{ parse: (str) => T, serialize: (val: T) => string } — composable, testable, framework-agnostic
- Browser rate limits: Safari limits
pushState/replaceState to ~100 calls per 30 seconds. Throttling is mandatory.
References
- nuqs — Best-in-class URL state for React/Next.js. Key patterns: adapter-based provider, internal Map queue with 50ms throttle,
createSearchParamsCache for server components using React cache(), parser objects with .withDefault() chaining
- use-query-params — Older, router-adapter based,
serialize-query-params package for pure serialization
- next-query-params — Thin Next.js adapter for use-query-params
Relationship to Persist Layer
This is conceptually a URL persist adapter — one instance of a broader persist layer pattern (see separate issue). The persist layer should define a generic interface that URL sync, localStorage, sessionStorage, etc. all implement.
Summary
Add a
useQueryState-style API that bidirectionally syncs comwit model fields with URL query parameters, SSR-friendly and Next.js App Router compatible.Motivation
URL-driven state is essential for shareable links, browser back/forward, and SSR hydration. Currently comwit handles client-side state well but has no built-in way to persist state to the URL.
Sync Direction
popstate/ router events and update state accordingly.Proposed API (Rough Sketch)
Model-level declaration
Provider-level config
SSR support (Next.js App Router)
Server components can't use hooks. Need a server-side utility to parse
searchParamsand pass to client:Key Design Considerations
queryParamfields changing in the same tick should produce a single URL update (queue + flush pattern, like nuqs's internal Map queue)history.replaceState/history.pushStatedirectly, bypassing Next.js router to avoid server re-renders. Opt-inshallow: falsefor cases that need server re-render.push(adds browser history entry) andreplace(default, no history entry) per field or per update{ parse: (str) => T, serialize: (val: T) => string }— composable, testable, framework-agnosticpushState/replaceStateto ~100 calls per 30 seconds. Throttling is mandatory.References
createSearchParamsCachefor server components using Reactcache(), parser objects with.withDefault()chainingserialize-query-paramspackage for pure serializationRelationship to Persist Layer
This is conceptually a URL persist adapter — one instance of a broader persist layer pattern (see separate issue). The persist layer should define a generic interface that URL sync, localStorage, sessionStorage, etc. all implement.