Search
A search box is the hardest simple case: it changes on every keystroke, it must survive a reload, and it must not turn the back button into a character-by-character undo.
The model
Section titled “The model”import { defineQueryModel, param } from "@queryweave/core";
export const products = defineQueryModel({ search: param.text({ trim: true }).optional(), page: param.integer({ min: 1 }).default(1),});optional() rather than .default(""): an absent search and an empty search are the same thing,
and undefined is the value that encodes to nothing.
Three decisions
Section titled “Three decisions”1. Use replace, not push
Section titled “1. Use replace, not push”const value = "vue";// current URL: /products?page=3await runtime.update({ search: value }, { navigation: "replace" });// written URL: /products?search=vue&page=3Each keystroke rewrites the current history entry instead of adding one. Back then leaves the search entirely, which is what a reader expects.
2. Clear with undefined
Section titled “2. Clear with undefined”const value = "";// current URL: /products?search=vueawait runtime.update({ search: value === "" ? undefined : value });// written URL after clearing: /productsAssigning "" produces ?search= in the URL and an empty issue when it is read back, because
param.text() rejects empty input. undefined omits the key.
3. Reset the page in the same transition
Section titled “3. Reset the page in the same transition”const value = "vue";// current URL: /products?search=nuxt&page=4await runtime.transaction( (draft) => { draft.search = value === "" ? undefined : value; draft.page = 1; }, { navigation: "replace" },);// written URL: /products?search=vueA new search on page 4 usually has no page 4. Doing both in one transaction produces one write, one history entry, and one render.
History1 / 1
- history.pushState
- history.replaceState
- popstate
The adapter writes through the History API and re-reads on popstate.
Type a query, press Enter.
Typed state
{
"page": 1,
"sort": "created_at",
"status": "all"
}Canonical URL
Valid/productsThrottling
Section titled “Throttling”Give the runtime a window and it writes at most once per window, so a burst of keystrokes becomes the first character and the final term rather than one write per key:
const runtime = createQueryRuntime({ model: products, adapter, throttle: 300,});Transitions that arrive inside the window are held and written together, in call order, and each
resolves with the write it took part in. There is nothing to debounce at the call site any more;
the handler above stays as it is. Keep replace all the same: a throttle reduces the number of
entries, it does not make them meaningful. Transitions has the
full rules.
In Vue
Section titled “In Vue”<script setup lang="ts">import { useQueryModel } from "@queryweave/vue";
const filters = useQueryModel(products, { throttle: 300 });const search = filters.field("search", { navigation: "replace" });</script>
<template> <input v-model="search" type="search" /></template>field gives you the v-model target, and it already clears the parameter when the input is
emptied; throttle on the binding holds a burst of its writes the same way. For the page reset,
use an explicit handler instead:
async function onSearch(value: string): Promise<void> { await filters.transaction( (draft) => { draft.search = value === "" ? undefined : value; draft.page = 1; }, { navigation: "replace" }, );}Reading it on the server
Section titled “Reading it on the server”import { readRequestQuery } from "@queryweave/server";
const result = readRequestQuery(request, products);const values = result.ok ? result.value : { ...products.defaults(), ...result.partial };
const rows = await search(values.search, values.page);The same model, the same defaults, no second parser.
What this does not solve
Section titled “What this does not solve”Request ordering. If a reader types faster than your data source responds, responses can arrive out
of order, and the runtime knows nothing about the requests a transition triggers. Compare the term
a response was fetched for with filters.values.search before rendering it, or hand the same
AbortController to the fetch and to the transition’s signal so that abandoning one abandons the
other.