Date range
A period is two params, startDate and endDate, with one rule between them: the start never comes after the end. The rule belongs to the pair rather than to either date, so it lives in a computed over the two states and not in a parser.
Show code
<script setup lang="ts">
import { computed } from 'vue'
import { parseAsDate } from 'usewagen'
import { useRouteStates } from 'usewagen/router'
type Range = [Date, Date]
const { startDate, endDate, set } = useRouteStates([
{ key: 'startDate', parser: parseAsDate },
{ key: 'endDate', parser: parseAsDate },
])
const range = computed({
get: (): Range | null => {
const start = startDate.value
const end = endDate.value
return start && end && start <= end ? [start, end] : null
},
set: (next: Range | null) => {
if (!next) {
set({ startDate: null, endDate: null })
return
}
const [start, end] = next[0] <= next[1] ? next : [next[1], next[0]]
set({ startDate: start, endDate: end })
},
})
function pick(index: 0 | 1, event: Event) {
const picked = (event.target as HTMLInputElement).valueAsDate
if (!picked) {
range.value = null
return
}
const next: Range = [startDate.value ?? picked, endDate.value ?? picked]
next[index] = picked
range.value = next
}
function asInput(date: Date | null) {
return date ? parseAsDate.serialize(date) : ''
}
</script>
<template>
<div class="demo-row">
<input type="date" :value="asInput(startDate)" @change="pick(0, $event)" />
<span>to</span>
<input type="date" :value="asInput(endDate)" @change="pick(1, $event)" />
<button :disabled="!startDate && !endDate" @click="range = null">Clear</button>
</div>
<p v-if="range">{{ asInput(range[0]) }} to {{ asInput(range[1]) }} is selected.</p>
<p v-else>No range is selected.</p>
</template>range is the pair the component works with, and it is what a range picker that takes both dates as one v-model binds to. Reading it puts the two params together, and writing it hands both to set, so the URL never carries a pair that is half updated. Pick a start after the end above: the two dates land swapped, in one navigation.
A URL edited by hand into the wrong order reads as null, the same way a param the parser refuses reads as missing, and it is left as it is until the next write. A parser answers for the one string it is given, and a rule over two strings has to wait until both are read, which is what the computed adds. Nothing else is needed, because the pair is still a function of the URL and nothing beside it.
One param instead
When the shape of the URL is yours to choose, the pair can be one param, and then the parser owns the rule.
import { defineParser, parseAsDate } from 'usewagen'
import { useRouteState } from 'usewagen/router'
const parseAsDateRange = defineParser<[Date, Date]>({
parse: raw => {
const [start, end] = raw.split('..').map(parseAsDate.parse)
return start && end && start <= end ? [start, end] : null
},
serialize: ([start, end]) => `${parseAsDate.serialize(start)}..${parseAsDate.serialize(end)}`,
})
const range = useRouteState({ key: 'range', parser: parseAsDateRange })?range=2026-01-01..2026-01-31 reads as the pair and anything else as null. Two params keep each date readable on its own and match what an existing link or API already uses; one param keeps the rule where the type is decided. Either way the rule is written once.