vue-router-query-sync

Reactive, typed view of Vue Router query params with codecs and defaults
2,354 1
Install
npm install vue-router-query-sync
Latest Version:2.0.0
Vue:3
License:MIT
Last Updated:Jul 15, 2026
Links: GitHub  ·  npm
Maintainer: ivanchikachev

vue-router-query-sync

Draft docs for 2.0.0-alpha.0. API only — no tests, migration guide or final docs yet.

A reactive, typed view of the Vue Router query string. The URL (route.query) is the single source of truth: reads parse route.query, writes navigate the router. The library holds no state of its own and never throws because of URL contents.

  • Vue 3 + Vue Router 4 (peer dependencies).
  • Codecs + defaults per field; defaults never appear in the URL.
  • Arrays use native repeated keys (?a=1&a=2).
  • Batched navigation: several writes in one tick collapse into one push/replace.
  • SSR-safe: router is read via useRouter()/useRoute(), no global/module state.

Install

npm install vue-router-query-sync

useQueryState(schema, options?)

Returns { state, patch, reset }. state is a reactive object of schema fields only (no methods on it); access fields without .value. The type is inferred from the schema.

import { useQueryState, field } from 'vue-router-query-sync'

enum SortType { Popular = 'popular', New = 'new' }

const { state, patch, reset } = useQueryState({
  sort:   field.enum(SortType, { default: SortType.Popular, history: 'push' }),
  brands: field.stringArray(),        // default: []
  page:   field.number({ default: 1 }),
  from:   field.custom(dateCodec),    // default: null
})

state.sort = SortType.New             // write -> router navigation
state.brands                          // read  -> parses route.query (or pending write)
patch({ brands: ['lg'], page: 2 })    // several fields, ONE navigation
reset()                               // all fields back to defaults (keys removed)
  • Read: pending write (same tick) → else route.query[key] → deserialize → on undefined/throw → default. Reading a field right after writing it returns the new value.
  • Write: serialize → if null or equal to serialize(default) → the key is removed.
  • Query keys not described in the schema are left untouched.
  • Destructuring state loses reactivity — use Vue's toRefs(state).

Single parameter

There is no separate single-param helper: use a one-key schema and toRef.

import { toRef } from 'vue'
import { useQueryState, field } from 'vue-router-query-sync'

const { state } = useQueryState({ tab: field.enum(['all', 'favorite'] as const) })
const tab = toRef(state, 'tab') // WritableComputedRef<'all' | 'favorite' | null>
tab.value = 'favorite'

prefix — namespacing keys

When the same schema is used more than once on a page (e.g. one paginated component per tab), pass a prefix so each instance owns its own URL keys and they don't collide. Field names in state stay unprefixed; only the URL key becomes ${prefix}-${key}.

const users  = useQueryState({ page: field.number({ default: 1 }) }, { prefix: 'users' })
const orders = useQueryState({ page: field.number({ default: 1 }) }, { prefix: 'orders' })

users.state.page = 3   // URL: ?users-page=3
orders.state.page = 5  // URL: ?users-page=3&orders-page=5

users.state.page       // reads users-page only — independent from orders

With a unique prefix per instance, keys never collide. Each instance keeps its own state in the URL, so switching between tabs and back restores their pagination; a direct link reproduces everything.

The prefix applies to the whole schema. To namespace only some keys, split by scope: keep shared keys in a plain (unprefixed) call and private keys in a prefixed one.

const { state: shared } = useQueryState({ sort: field.enum(SortType) })            // ?sort=…
const { state }         = useQueryState({ page: field.number({ default: 1 }) },
                                        { prefix: 'users' })                        // ?users-page=…

If you'd rather keep the URL minimal, render inactive tabs with v-if and call reset() in onUnmounted to drop the leaving tab's keys.

field

field.* builds a schema field: its type, default, history, and codec. Options are shared across all types: { default?: T; history?: 'push' | 'replace' } (history defaults to 'replace'). Passing default narrows the field type from T | null to T.

Builder Field type Notes
field.string(options?) string | null
field.number(options?) number | null
field.boolean(options?) boolean | null URL is 'true' / 'false'
field.stringArray(options?) string[] repeated keys; default always []
field.enum(input, options?) member of input see below
field.custom(codec, options?) T from codec default null if unset

field.enum accepts either a readonly string array or a TS enum object (string or numeric). Numeric enums have their reverse mapping filtered out and URL strings are coerced to numbers. A value outside the set becomes undefined → default (behaviour mirrors z.enum).

Codecs

A codec is the low-level serialize/deserialize pair a field is built on. Use field.custom with defineCodec for types beyond the built-ins.

import { defineCodec, type QueryCodec } from 'vue-router-query-sync'

interface QueryCodec<T> {
  serialize(value: T): string | string[] | null    // null = remove key
  deserialize(raw: string | string[]): T | undefined // undefined = use default
}

const dateCodec = defineCodec<Date | null>({
  serialize: (d) => (d ? d.toISOString().slice(0, 10) : null),
  deserialize: (raw) => {
    const t = Date.parse(Array.isArray(raw) ? raw[0] : raw)
    return Number.isNaN(t) ? undefined : new Date(t)
  },
})

defineCodec is an identity helper for type inference. Every built-in field.* type is backed by a codec internally.

Batching & history

All writes in the same tick — field assignments, patch, reset — accumulate into one pending batch (kept per router instance) and are applied as a single navigation via queueMicrotask. The batch is layered over the last scheduled state, so reads within the same tick see pending writes. If any changed field in the batch has history: 'push', the whole batch uses router.push; otherwise router.replace. Query keys keep insertion order — existing keys stay in place, newly added keys are appended (e.g. ?tab=users&page=2).

Exports

import {
  useQueryState,
  field,
  defineCodec,
  type QueryCodec,
  type FieldOptions,
  type Field,
  type QuerySchema,
  type QueryValues,
  type UseQueryStateOptions,
  type UseQueryStateReturn,
} from 'vue-router-query-sync'

Playground

npm run dev

A catalog page exercises every field type plus reset/patch, and a second route shows the single-parameter pattern (toRef over a one-key schema). The current route.query is rendered on screen.

License

MIT © Ivan Chikachev

Related Packages

vue-url-state

A Vue 3 composable for syncing reactive state with URL query parameters

2,144 2
use-query-sync-data

A composition API that sync url query to data base on Vue3.

718 0
@mpxjs/pinia

A pinia style store in miniprogram

24,265 3,947
vue-sync-query-mixin

Effortlessly keep local state and $route.query in sync for Vue 1.x|2.x

829 6
pinia-plugin-router

A pinia plugin that help use vue-router in pinia

4,356

Version History

Version Released Vue License
2.0.0 ^3.3.13 MIT
1.0.1 ^3.3.13 MIT
1.0.0 ^3.3.13 MIT
0.0.13 ^3.3.13 MIT
0.0.11 ^3.3.13 MIT
Pre-releases (8)
Version Released Vue License
2.0.1-alpha.0 ^3.3.13 MIT
2.0.0-alpha.7 ^3.3.13 MIT
2.0.0-alpha.6 ^3.3.13 MIT
2.0.0-alpha.5 ^3.3.13 MIT
2.0.0-alpha.4 ^3.3.13 MIT