Created
May 22, 2026 19:30
-
-
Save markusand/cc4c6188884be6ee8244ea9543ec4a83 to your computer and use it in GitHub Desktop.
Vue 3 composable to sync reactive state with URL query string parameters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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