Skip to content

Instantly share code, notes, and snippets.

@markusand
Created May 22, 2026 19:30
Show Gist options
  • Select an option

  • Save markusand/cc4c6188884be6ee8244ea9543ec4a83 to your computer and use it in GitHub Desktop.

Select an option

Save markusand/cc4c6188884be6ee8244ea9543ec4a83 to your computer and use it in GitHub Desktop.
Vue 3 composable to sync reactive state with URL query string parameters
import { reactive, watchEffect } from 'vue'
/* eslint-disable @typescript-eslint/no-explicit-any */
type QsType = string | number | boolean | Date | unknown[]
/** Returns the lowercase type name of a value (e.g. 'string', 'date', 'array') */
const typeOf = (value: unknown): string =>
Object.prototype.toString.call(value).slice(8, -1).toLowerCase()
/** Infers the type name of a serialized query string value */
const inferType = (value: string): string => {
const trimmed = value.trim()
if (trimmed === 'null' || trimmed === '') return 'null'
if (trimmed === 'true' || trimmed === 'false') return 'boolean'
if (!isNaN(+trimmed)) return 'number'
if (trimmed.includes('T') && !isNaN(Date.parse(trimmed))) return 'date'
return 'string'
}
/** Converts typed values to their query string representation */
const SERIALIZERS: Record<string, (value: any) => string> = {
number: (value: number) => `${value}`,
string: (value: string) => value,
boolean: (value: boolean) => (value ? 'true' : 'false'),
null: () => '',
date: (value: Date) => value.toISOString(),
array: (value: unknown[]) =>
value
.map((v) => SERIALIZERS[typeOf(v)]?.(v))
.filter(Boolean)
.join(','),
}
/** Converts query string values back to their typed representation */
const PARSERS: Record<string, (value: string) => unknown> = {
number: (value: string) => +value,
string: (value: string) => value,
boolean: (value: string) => value === 'true',
null: () => null,
date: (value: string) => {
const date = new Date(value)
return isNaN(date.getTime()) ? '' : date
},
array: (value: string) =>
value
.split(',')
.filter(Boolean)
.map((v) => PARSERS[inferType(v)]?.(v) ?? `${v}`),
}
/**
* Syncs a reactive state object with the URL query string.
* Values are initialized from the URL if present, otherwise from the provided defaults.
* Any state change is automatically reflected in the URL via replaceState.
*/
export default (input: Record<string, QsType>) => {
const qs = new URLSearchParams(window.location.search)
const state = reactive(
Object.fromEntries(
Object.entries(input).map(([key, initial]) => {
const value = qs.get(key)
return [key, value !== null ? (PARSERS[typeOf(initial)]?.(value) ?? value) : initial]
}),
),
)
watchEffect(() => {
const url = new URL(window.location.href)
const qs = new URLSearchParams(
Object.entries(state)
.map(([key, value]) => [key, SERIALIZERS[typeOf(value)]?.(value) || String(value)])
.filter(([, value]) => !!value),
)
url.search = qs.toString()
window.history.replaceState(null, '', url)
})
return state
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment