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/productsDebouncing
Section titled “Debouncing”The runtime writes immediately, so debounce at the call site if you do not want one write per keystroke:
let timer: ReturnType<typeof setTimeout> | undefined;
function onInput(value: string): void { clearTimeout(timer); timer = setTimeout(() => { void runtime.transaction( (draft) => { draft.search = value === "" ? undefined : value; draft.page = 1; }, { navigation: "replace" }, ); }, 200);}Even debounced, keep replace: a debounce reduces the number of entries, it does not make them
meaningful.
In Vue
Section titled “In Vue”<script setup lang="ts">import { useQueryModel } from "@queryweave/vue";
const filters = useQueryModel(products);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. 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”Coalescing. Transitions on one runtime are serialized, so a burst of keystrokes never loses a character — but each keystroke is still its own write. Debounce for the history stack’s sake, and because Safari refuses more than about a hundred history writes in ten seconds. If a user types faster than your data source responds, responses can also arrive out of order; QueryWeave does not cancel transitions, so ordering your own requests is your responsibility. That capability is planned.