Type-safe, reactive URL state management for Vue.
qpick — query + pick. Cherry-pick parameters from the URL as reactive state.
qpick is a Vue composable built on vue-router that turns URL parameters into writable computed refs. Instead of manually reading route.query, watching for changes, and navigating back — the URL becomes the source of truth with a declarative config.
- Works with query strings and path parameters
- Full type inference from parser definitions
- Supports
v-modeldirectly - Single or batch parameter updates
- No plugin required — optional global defaults via
defineQPick
pnpm add qpick<scriptsetuplang="ts">import{useRouteState}from'qpick'// string | null — reads from ?q=...constsearch=useRouteState({key: 'q'})</script><template><input:value="search ?? ''"
placeholder="Search..."
@input="search = ($event.target as HTMLInputElement).value || null"
></template>Setting search.value = 'headphones' navigates to /?q=headphones. Setting it to null removes the parameter.
Tip:
parseAsString.default('')eliminatesnullhandling and enables directv-model:import{parseAsString,useRouteState}from'qpick'constsearch=useRouteState({key: 'q',parser: parseAsString.default('')})// search.value is string — never null<inputv-model="search" placeholder="Search...">
URL parameters are strings. Parsers handle bidirectional conversion between the URL string and the typed value. Each parser defines parse (string → value | null) and serialize (value → string).
import{parseAsInteger,useRouteState}from'qpick'constpage=useRouteState({key: 'page',parser: parseAsInteger})// page.value is number | nullThe .default() method guarantees a non-null value and enables clearOnDefault behavior:
constpage=useRouteState({key: 'page',parser: parseAsInteger.default(1)})// page.value is number — never null// Setting page.value = 1 removes ?page from the URL| Parser | Type | URL Example | Description |
|---|---|---|---|
parseAsString | string | ?q=headphones | Raw string value |
parseAsInteger | number | ?page=2 | parseInt base 10 |
parseAsFloat | number | ?lat=41.015 | parseFloat |
parseAsBoolean | boolean | ?inStock=true | Accepts "true" / "false" |
parseAsIndex | number | ?step=1 → 0 | 1-based in URL, 0-based in code |
parseAsStringLiteral([...]) | Union | ?sort=price | Validates against allowed values |
parseAsNumberLiteral([...]) | Union | ?rating=5 | Validates against allowed numbers |
parseAsStringEnum(values) | Enum | ?status=ACTIVE | For TypeScript string enums |
parseAsDate | Date | ?from=2024-01-15 | YYYY-MM-DD format |
parseAsDate.iso() | Date | ?from=2024-01-15T10:30:00.000Z | Full ISO 8601 |
parseAsDate.timestamp() | Date | ?t=1705312200000 | Unix milliseconds |
parseAsArrayOf(parser) | T[] | ?tags=vue,ts | Comma-separated (configurable) |
parseAsJson<T>() | T | ?config={"k":"v"} | JSON-encoded values |
constsort=useRouteState({key: 'sort',parser: parseAsStringLiteral(['price','name','rating']).default('price'),})// sort.value is 'price' | 'name' | 'rating'For TypeScript string enums:
enumOrderStatus{Active='ACTIVE',Completed='COMPLETED',Cancelled='CANCELLED',}conststatus=useRouteState({key: 'status',parser: parseAsStringEnum<OrderStatus>(Object.values(OrderStatus)),})constcategories=useRouteState({key: 'categories',parser: parseAsArrayOf(parseAsString).default([]),})// ?categories=electronics,clothing → ['electronics', 'clothing']// Custom separatorconstprices=useRouteState({key: 'prices',parser: parseAsArrayOf(parseAsInteger,';').default([]),})// ?prices=100;500;1000 → [100, 500, 1000]import{defineParser,useRouteState}from'qpick'// Single-expression parsers work well as arrow functionstypeHexColor=number// 0x000000–0xFFFFFFconstparseAsHexColor=defineParser<HexColor>({parse: v=>Number.isNaN(Number.parseInt(v,16)) ? null : Number.parseInt(v,16),serialize: v=>v.toString(16).padStart(6,'0'),})// ?color=ff5733 → 16734003// Method shorthand works the same wayinterfacePriceRange{min: numbermax: number}constparseAsPriceRange=defineParser<PriceRange>({parse(value){constparts=value.split('-')if(parts.length!==2)returnnullconstmin=Number(parts[0])constmax=Number(parts[1])if(Number.isNaN(min)||Number.isNaN(max))returnnullreturn{ min, max }},serialize: v=>`${v.min}-${v.max}`,})constpriceRange=useRouteState({key: 'price',parser: parseAsPriceRange.default({min: 0,max: 1000}),})// ?price=50-200 → { min: 50, max: 200 }useRouteState also accepts an array of configs to manage related parameters together:
<scriptsetuplang="ts">import{parseAsInteger,parseAsString,parseAsStringLiteral,useRouteState}from'qpick'constfilters=useRouteState([{key: 'q',parser: parseAsString.default('')},{key: 'page',parser: parseAsInteger.default(1)},{key: 'sort',parser: parseAsStringLiteral(['price','name','rating']).default('price')},])</script><template><inputv-model="filters.q.value" placeholder="Search..."><selectv-model="filters.sort.value"><optionvalue="price">Price</option><optionvalue="name">Name</option><optionvalue="rating">Rating</option></select><button@click="filters.page.value++">Next page</button></template>Note: In single config mode,
useRouteStatereturns a ref directly — Vue auto-unwraps it in templates. In array mode, each property is a ref, sov-modelrequires.value(e.g.v-model="filters.q.value").
set() updates multiple parameters in a single navigation:
functiononSearch(term: string){filters.set({q: term,page: 1})}// Override history mode for this callfilters.set({q: term,page: 1},{history: 'replace'})Restores all parameters to defaults in a single navigation:
filters.reset()filters.reset({history: 'replace'})Returns current values as a plain object:
watch(()=>filters.toObject(),(params)=>{fetchProducts(params)})The urlKey option maps a code-friendly property name to a different URL parameter name:
constfilters=useRouteState([{key: 'search',parser: parseAsString.default(''),urlKey: 'q'},{key: 'sortOrder',parser: parseAsStringLiteral(['asc','desc']).default('asc'),urlKey: 'dir'},])// Code: filters.search.value, filters.sortOrder.value// URL: ?q=headphones&dir=descWhen a key matches a named route parameter, qpick reads from path params automatically:
// Route: /products/:idconstid=useRouteState({key: 'id',parser: parseAsInteger})constid=useRouteState({key: 'id',parser: parseAsInteger,source: 'params'})// Route: /users/:idconststate=useRouteState([{key: 'userId',parser: parseAsInteger,source: 'params',urlKey: 'id'},{key: 'tab',parser: parseAsStringLiteral(['profile','orders','settings']).default('profile'),source: 'query'},{key: 'page',parser: parseAsInteger.default(1),source: 'query'},])Note: Each
keyin the config array must be unique — it becomes the property name on the returned object. Duplicate keys are not detected at runtime; the last config silently overwrites earlier ones. When the same URL parameter name exists in both path and query, different keys with the sameurlKeydistinguish them:
// Route: /brands/:id?id=456// A brand page that highlights a specific productconststate=useRouteState([{key: 'brandId',parser: parseAsInteger,source: 'params',urlKey: 'id'},{key: 'productId',parser: parseAsInteger,source: 'query',urlKey: 'id'},])// state.brandId.value → route.params.id// state.productId.value → route.query.idControls whether changes push a new history entry or replace the current one. Default: 'push'.
constsearch=useRouteState({key: 'q',parser: parseAsString.default(''),history: 'replace'})'replace' updates the URL without adding a new history entry. This is useful in scenarios where the parameter changes frequently — such as a search input updating on every keystroke or a range slider adjusting continuously.
When set() or reset() involves configs with different history modes:
- Call-site override wins —
{ history }passed toset()/reset()is used unconditionally. - Push wins — If any config in the batch has
history: 'push', the navigation uses push. - Replace as fallback — Only when all configs specify
'replace'.
conststate=useRouteState([{key: 'q',parser: parseAsString.default(''),history: 'replace'},{key: 'page',parser: parseAsInteger.default(1),history: 'push'},])state.set({q: 'vue',page: 2})// push winsstate.set({q: 'react'})// only q → replacestate.set({q: 'vue',page: 2},{history: 'replace'})// override → replaceRemoves the parameter from the URL when its value equals the default. Enabled by default.
constpage=useRouteState({key: 'page',parser: parseAsInteger.default(1),clearOnDefault: false})// ?page=1 stays in the URLdefineRouteStateOptions() provides type inference for configs defined outside of useRouteState:
// composables/states.tsimport{defineRouteStateOptions,parseAsInteger,parseAsString}from'qpick'exportconstsearchState=defineRouteStateOptions({key: 'search',parser: parseAsString.default(''),urlKey: 'q',})exportconstpageState=defineRouteStateOptions({key: 'page',parser: parseAsInteger.default(1),})Shared configs keep components in sync — they read from and write to the same URL:
<!-- ProductList.vue --><scriptsetuplang="ts">import{useRouteState}from'qpick'import{pageState,searchState}from'@/composables/states'constfilters=useRouteState([searchState,pageState])</script><!-- Pagination.vue --><scriptsetuplang="ts">import{useRouteState}from'qpick'import{pageState}from'@/composables/states'constpage=useRouteState(pageState)</script><template><button:disabled="page <= 1" @click="page--">Previous</button><span>Page {{ page }}</span><button@click="page++">Next</button></template>useRouteState works without any plugin. defineQPick is only needed to override global defaults:
import{defineQPick}from'qpick'app.use(defineQPick({defaults: {history: 'push',// 'push' | 'replace' — default: 'push'clearOnDefault: true,// default: true},}))Without the plugin, built-in defaults (history: 'push', clearOnDefault: true) are used.
All types are inferred from parser definitions:
constpage=useRouteState({key: 'page',parser: parseAsInteger})// WritableComputedRef<number | null>constpage=useRouteState({key: 'page',parser: parseAsInteger.default(1)})// WritableComputedRef<number>The InferParserType utility extracts the value type from a parser:
importtype{InferParserType}from'qpick'typePageValue=InferParserType<typeofparseAsInteger>// number | null