vue-route-state
v1.2.0
Published
Minimal Vue 3 composables for keeping reactive state in Vue Router query parameters.
Maintainers
Readme
vue-route-state
vue-route-state is a small Vue 3 library for storing reactive state in Vue Router query parameters.
The API is intentionally close to useState, but the URL is the source of truth. Values are read from route.query, and writes use router.replace() by default, so browser reload, manual URL edits, back, and forward navigation update the returned refs automatically.
Links
Install
npm install vue-route-statePeer dependencies:
npm install vue vue-routerCompatibility
The public API is stable as of 1.0.0. Breaking changes are reserved for major versions.
The package targets Vue 3 and Vue Router 4 at runtime. Node.js >=18 is supported for package installation. Use Node.js 24 for repository tooling, builds, tests, and package validation.
Public API
export { useUrlParam, useUrlQueryParam, useUrlState }useUrlParam
import { useUrlParam } from 'vue-route-state'
const search = useUrlParam('search', {
type: 'string',
defaultValue: '',
})
search.value = 'example'The URL becomes:
?search=exampleDeleting a value removes the query parameter:
search.value = nullPass both route and router when a wrapper or test already has an explicit router context. Use a reactive route source, such as useRoute() or router.currentRoute:
const search = useUrlParam('search', {
type: 'string',
defaultValue: '',
route: router.currentRoute,
router,
})useUrlQueryParam
Use useUrlQueryParam when a parameter needs custom parsing or serialization instead of a built-in codec. It is the low-level primitive behind custom URL state wrappers: the library keeps Vue Router wiring, reactivity, query preservation, and navigation mode handling, while your code defines how the raw query value becomes application state.
The parser receives the raw Vue Router query value and defaultValue. The serializer receives the next value and defaultValue. Returning undefined, null, or an empty string removes the query parameter.
Writes use router.replace() by default. Pass history: 'push' to use router.push().
Pass both route and router when a wrapper or test already has an explicit router context. Use a reactive route source, such as useRoute() or router.currentRoute:
const payload = useUrlQueryParam('payload', {
defaultValue: {},
parse() {},
serialize() {},
route: router.currentRoute,
router,
})Legacy URL Values
Use a custom parser when the URL format is fixed by older links or another application, but your component wants a different value shape.
import { useUrlQueryParam } from 'vue-route-state'
const archived = useUrlQueryParam('archived', {
defaultValue: false,
parse(value, defaultValue) {
if (value == null || value === '') {
return defaultValue
}
return value === 'yes'
},
serialize(value) {
return value ? 'yes' : null
},
})This reads ?archived=yes as true, treats missing values as false, and removes the parameter when the value is false.
Structured Values
Use a custom serializer when one query parameter represents a small structured value.
const sort = useUrlQueryParam('sort', {
defaultValue: { key: 'name', order: 'asc' },
parse(value, defaultValue) {
const raw = Array.isArray(value) ? value[0] : value
const [key, order] = String(raw || '').split(':')
if (!key || !['asc', 'desc'].includes(order)) {
return defaultValue
}
return { key, order }
},
serialize(value, defaultValue) {
if (value.key === defaultValue.key && value.order === defaultValue.order) {
return null
}
return value.key + ':' + value.order
},
})This maps ?sort=name:desc to { key: 'name', order: 'desc' }.
Compact JSON Payloads
JSON can be useful for compact internal state, but prefer readable query parameters for public or shareable URLs.
const payload = useUrlQueryParam('payload', {
defaultValue: {},
parse(value, defaultValue) {
const raw = Array.isArray(value) ? value[0] : value
if (!raw) {
return defaultValue
}
try {
return JSON.parse(String(raw))
} catch {
return defaultValue
}
},
serialize(value, defaultValue) {
return value === defaultValue ? null : JSON.stringify(value)
},
})useUrlState
import { useUrlState } from 'vue-route-state'
const state = useUrlState({
search: {
type: 'string',
defaultValue: '',
},
page: {
type: 'number',
defaultValue: 1,
positive: true,
integer: true,
},
enabled: {
type: 'boolean',
defaultValue: false,
},
periodStart: {
type: 'date',
key: 'period_start',
defaultValue: null,
},
tags: {
type: 'array',
key: 'tags[]',
aliases: ['tags'],
defaultValue: [],
},
order: {
type: 'string',
defaultValue: 'newest',
allowedValues: ['newest', 'oldest'],
},
sort: {
type: 'custom',
defaultValue: { key: 'name', order: 'asc' },
parse(raw, field) {
const [key, order] = String(raw || '').split(':')
return key && order ? { key, order } : field.defaultValue
},
serialize(value) {
return value.key + ':' + value.order
},
},
})
state.search.value = 'hello'
state.page.value = 3Each schema field is returned as a writable computed ref.
Schema
Supported field options:
{
type,
key,
aliases,
defaultValue,
allowedValues,
positive,
integer,
omitDefault,
enabledWhen,
transform,
parse,
serialize,
}key defaults to the schema field name. omitDefault defaults to true, so assigning the default value removes the parameter from the URL. Set omitDefault: false to write default values explicitly.
Schema field names cannot use returned helper names: patch, clear, reset, snapshot, values, or hasQueryValue.
Use transform(value, field) to normalize values after parsing and before serialization:
search: {
type: 'string',
defaultValue: '',
transform(value) {
return String(value).trim()
},
}Explicit Router Context
useUrlState reads Vue Router from app context by default. Pass both route and router when building wrappers or tests that already have an explicit router context. Use a reactive route source, such as useRoute() or router.currentRoute:
const state = useUrlState(schema, {
route: router.currentRoute,
router,
})Both values are required. Passing only route or only router throws an error. Do not pass a one-time router.currentRoute.value snapshot if the returned refs should react to later navigation.
Conditional fields
enabledWhen and the order option apply to useUrlState().
Use enabledWhen for fields that only apply when other URL state has a
particular value:
const state = useUrlState(
{
mode: {
type: 'string',
defaultValue: 'simple',
allowedValues: ['simple', 'advanced'],
},
detail: {
type: 'string',
defaultValue: '',
enabledWhen: ({ values }) => values.mode === 'advanced',
},
},
{
order: ['mode', 'detail'],
},
)The predicate receives:
{
field, // current schema field name
values, // values for the current read or update
query, // query being read or updated
route, // current Vue Router route
}Fields listed in order are resolved first. Remaining fields follow their
schema declaration order. This makes dependencies deterministic even when a
dependent field is declared before the field it reads. Fields placed before the
current field are guaranteed to be normalized before its predicate runs.
When enabledWhen returns false, the field reads as its defaultValue.
During the next state write, its primary key and aliases are removed from the
URL. patch() can enable a field and assign its value in the same call.
Field groups
Use groups when several fields share the same availability rule:
const state = useUrlState(
{
view: {
type: 'string',
defaultValue: 'list',
allowedValues: ['list', 'details'],
},
detailTab: {
type: 'string',
key: 'detail_tab',
defaultValue: 'summary',
},
detailPage: {
type: 'number',
key: 'detail_page',
defaultValue: 1,
positive: true,
integer: true,
},
},
{
order: ['view', 'detailTab', 'detailPage'],
groups: {
details: {
fields: ['detailTab', 'detailPage'],
enabledWhen: ({ values }) => values.view === 'details',
},
},
},
)When a group is disabled, its fields read as their defaultValue. During the
next state write, their primary keys and aliases are removed from the URL by
default. Set clearWhenDisabled: false to preserve disabled group query keys
while still reading default values.
Group predicates receive the same context as field predicates plus group,
the group name.
Types
string reads the first query value when Vue Router provides an array. Missing or empty values return defaultValue.
number supports finite JavaScript numbers using Number(value). Invalid values such as abc, NaN, and values less than or equal to zero with positive: true return defaultValue. Set integer: true to reject fractional values such as 2.5. 0 is valid unless positive: true is set.
boolean reads true, false, 1, and 0. It writes canonical values as true and false by default. Set trueValue and falseValue on a boolean field to write custom canonical values, such as 1 and 0. false is a valid value.
date supports YYYY-MM-DD strings and valid Date objects. Invalid dates such as today, 2026-99-99, and 2026-02-31 return defaultValue. Dates are written as YYYY-MM-DD.
custom delegates parsing and serialization to field-level parse(raw, field) and serialize(value, field) functions. Use it when a field needs to store structured values or a URL format that built-in codecs do not cover. Returning undefined, null, or an empty string from serialize removes the query parameter.
array supports arrays of strings. It reads repeated query parameters and comma-separated fallback values, such as ?tags[]=one&tags[]=two and ?tags=one,two. Empty arrays remove the query parameter. Objects in arrays are not supported. With allowedValues, arrays filter invalid items by default. Set invalidValues: 'default' to return defaultValue when any item is unsupported.
patch
Use patch() for related changes. It performs one router navigation and preserves unmanaged query parameters.
await state.patch({
search: 'hello',
page: 1,
enabled: true,
})patch() updates only provided fields. undefined means “do not change this field”. null removes the field from the URL. Unknown fields throw vue-route-state: Unknown URL state field: name.
The second argument can override the history mode for one action:
await state.patch(
{
search: 'hello',
page: 1,
},
{
history: 'push',
},
)Sequential assignments are supported, but they can create separate router navigations:
state.search.value = 'hello'
state.page.value = 2For connected updates, prefer patch().
clear
await state.clear('search')
await state.clear(['search', 'page'])This removes only the selected managed parameters. Pass one field name or an array of field names. Without arguments, it removes all parameters managed by the schema:
await state.clear()Unmanaged query parameters are preserved.
reset
await state.reset()
await state.reset('page')
await state.reset(['page', 'order'])reset() accepts one field name, an array of field names, or no argument for all fields. It assigns defaultValue for selected fields. Normal serialization rules still apply, including omitDefault.
snapshot and values
const values = state.snapshot()snapshot() returns a detached plain object. Mutating it does not change URL state.
state.values.valuevalues is a computed ref containing current parsed values for the whole schema.
hasQueryValue
Use hasQueryValue() when the application needs to distinguish an absent
parameter from an explicitly provided value:
state.page.value // 1 for both URLs
// /items
state.hasQueryValue('page') // false
// /items?page=1
state.hasQueryValue('page') // trueThe method checks both the primary key and its aliases. It reports whether
the query key is present, independently of parsing: an invalid value such as
?page=invalid is present even when state.page.value falls back to its
defaultValue.
aliases
Aliases are read-only fallback keys.
tags: {
type: 'array',
key: 'tags[]',
aliases: ['tags'],
defaultValue: [],
}Rules:
- The primary
keyhas priority. - Aliases are checked only when the primary key is absent.
- Writes always use the primary key.
- Writes remove stale alias keys.
allowedValues
order: {
type: 'string',
defaultValue: 'newest',
allowedValues: ['newest', 'oldest'],
}For scalar types, if the URL contains an unsupported value, reading returns defaultValue.
For arrays, invalid values are filtered by default because each item is independent:
tags: {
type: 'array',
defaultValue: [],
allowedValues: ['a', 'b', 'c'],
invalidValues: 'filter',
}Use invalidValues: 'default' to make an array return defaultValue when any item is unsupported.
History
Writes use router.replace() by default.
useUrlState(schema, {
history: 'replace',
})Use history: 'push' to create browser history entries:
useUrlParam('page', {
type: 'number',
defaultValue: 1,
history: 'push',
})No-op updates do not call router.replace() or router.push().
Query writes preserve the current route target. Named routes keep their name,
params, and hash; unnamed routes keep their resolved path and hash.
patch(), clear(), and reset() can override the configured mode for one
navigation:
await state.clear('search', {
history: 'push',
})
await state.reset('page', {
history: 'replace',
})Universal Example
import { computed } from 'vue'
import { useUrlState } from 'vue-route-state'
const state = useUrlState({
search: {
type: 'string',
defaultValue: '',
},
page: {
type: 'number',
defaultValue: 1,
positive: true,
},
limit: {
type: 'number',
defaultValue: 20,
positive: true,
},
sort: {
type: 'string',
defaultValue: 'newest',
allowedValues: ['newest', 'oldest', 'name'],
},
tags: {
type: 'array',
key: 'tags[]',
aliases: ['tags'],
defaultValue: [],
},
enabled: {
type: 'boolean',
defaultValue: false,
},
})
await state.patch({
search: 'example',
page: 1,
sort: 'name',
})
const requestParams = computed(() => ({
search: state.search.value,
page: state.page.value,
limit: state.limit.value,
sort: state.sort.value,
tags: state.tags.value,
enabled: state.enabled.value,
}))Building API requests remains the application's responsibility.
Architecture
The library is split into small modules:
codecs/parse and serialize supported field types.core/create-field.jscreates writable computed refs and reads current values.core/update-query.jsrebuilds managed query state, preserves unmanaged keys, and performs no-op detection.helpers/contains schema normalization, query helpers, equality checks, and Vue Router context validation.composables/exposesuseUrlParamanduseUrlState.
Development
Use Node.js 24 for local development. The repository tooling follows the packageManager value declared in package.json.
The repository uses a single root eslint.config.js for library source, tests, examples, and playground files.
ESLint checks:
- no
console.log; - no unused variables;
- import order, duplicate imports, and imports before executable code;
- Vue recommended rules for
.vuefiles.
Prettier handles formatting through the root .prettierrc.
The repository also contains the docs and playground app in playground/. It is built as the public demo site for GitHub Pages and groups examples by scenario: search, pagination, filters, legacy URLs, and custom codecs. Pushes to main deploy it through GitHub Actions.
The app imports the local library source through a Vite alias, so it reflects changes in src/ immediately.
Commands
npm install
npm run lint
npm run lint:fix
npm run format
npm run format:check
npm run test
npm run build
npm run docs:dev
npm run docs:build
npm run docs:preview
npm run package:check