Skip to content
QueryWeave

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.

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.

const value = "vue";
// current URL: /products?page=3
await runtime.update({ search: value }, { navigation: "replace" });
// written URL: /products?search=vue&page=3

Each keystroke rewrites the current history entry instead of adding one. Back then leaves the search entirely, which is what a reader expects.

const value = "";
// current URL: /products?search=vue
await runtime.update({ search: value === "" ? undefined : value });
// written URL after clearing: /products

Assigning "" produces ?search= in the URL and an empty issue when it is read back, because param.text() rejects empty input. undefined omits the key.

const value = "vue";
// current URL: /products?search=nuxt&page=4
await runtime.transaction(
(draft) => {
draft.search = value === "" ? undefined : value;
draft.page = 1;
},
{ navigation: "replace" },
);
// written URL: /products?search=vue

A new search on page 4 usually has no page 4. Doing both in one transaction produces one write, one history entry, and one render.

Type, then clear the field. The key appears and disappears; the page resets with each new term.

History1 / 1

Products

6 matching

  • Edge runtime handbookactive$59
  • Node.js request toolkitactive$39
  • Nuxt deployment guidearchived$19
  • 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
/products

    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.

    <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" },
    );
    }
    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.

    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.