Skip to content

feat: URL query string sync (useQueryState) #18

@Moon-DaeSeung

Description

@Moon-DaeSeung

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

  1. Initial load (URL → state): On SSR or first client render, parse URL search params and hydrate the model field.
  2. Subsequent updates (state → URL): When the model field changes via actions, push the new value to the URL query string.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions