Vue
Framework
Readonly reactive values and explicit operations, bound to one model.
- Package
- @queryweave/vue
- Runtime
- Vue application
- Depends on
- @queryweave/core
- Requires
- vue
useQueryModel binds one model to Vue reactivity. It adds reactivity and lifecycle cleanup. It
does not add a second way to decode, a different default rule, or a navigation policy of its own.
Install
Section titled “Install”pnpm add @queryweave/core @queryweave/vueVue is a peer dependency. This package does not depend on Vue Router, on Nuxt, or on browser globals.
Example
Section titled “Example”<script setup lang="ts">import { createBrowserAdapter } from "@queryweave/browser";import { defineQueryModel, param } from "@queryweave/core";import { provideQueryAdapter, useQueryModel } from "@queryweave/vue";
const products = defineQueryModel({ search: param.text().optional(), page: param.integer({ min: 1 }).default(1),});
provideQueryAdapter(createBrowserAdapter());
const filters = useQueryModel(products);const search = filters.field("search", { navigation: "replace" });</script>
<template> <input v-model="search" type="search" />
<p>Page {{ filters.values.page }}</p> <button @click="filters.update({ page: filters.values.page + 1 })">Next</button>
<ul v-if="filters.status === 'invalid'"> <li v-for="issue in filters.issues" :key="issue.key">{{ issue.message }}</li> </ul></template>Supplying the adapter
Section titled “Supplying the adapter”Three ways, in order of preference:
// 1. Provide once, high in the tree. Every binding finds it.provideQueryAdapter(createBrowserAdapter());const provided = useQueryModel(products);
// 2. Pass one directly.const explicit = useQueryModel(products, { adapter });
// 3. Reuse a runtime you already own.const shared = useQueryModel(products, { runtime });provideQueryAdapter() reaches bindings in the same component and in every component below it, so
the example at the top of this page works as written, in one setup(). A component that provides its own adapter wins over one
provided by an ancestor.
An app without a root component that owns the adapter can provide it once for the whole app instead:
app.provide(queryAdapterKey, createBrowserAdapter());queryAdapterKey is exported from @queryweave/vue.
If none of the three is available, useQueryModel throws with a message naming all three. It never
silently falls back to a global. Injection works inside a component and anywhere else Vue has an
injection context, such as app.runWithContext() — which is how a Nuxt plugin or a Pinia store can
call it.
The binding
Section titled “The binding”const filters = useQueryModel(products);
filters.values; // readonly reactive statefilters.status; // "valid" | "invalid" | "pending"filters.issues; // readonly QueryIssue[]filters.runtime; // the underlying QueryRuntime
await filters.update({ page: 2 });await filters.replace(next);await filters.remove(["search"]);await filters.reset();await filters.transaction((draft) => { draft.search = "vue"; draft.page = 1;});await filters.settled(); // once an asynchronous refinement has settledThe operations are the runtime’s, with the binding’s default navigation applied, and each resolves
with the transition’s outcome. status is "pending" only while a model with an asynchronous
refinement is being decoded; the binding updates itself when it settles.
Field bindings
Section titled “Field bindings”field returns a writable computed for one key — a v-model target:
const search = filters.field("search", { navigation: "replace" });const page = filters.field("page");Reading it reads the current value. Writing it runs update for that key with the navigation mode
you gave. This is the only place mutation-shaped syntax exists, and it is still one named operation
underneath. Writing an empty string clears the parameter — an emptied input means “no value”, so
search becomes absent rather than ?search=. Rapid writes are serialized by the runtime, so none
is lost, and useQueryModel(model, { throttle: 300 }) holds a burst of them and writes it together
when the window closes. A write is not awaited, and a rejected one surfaces as an unhandled
rejection.
There is deliberately no per-key hook and no tuple setter. One model produces one binding; see ADR 0006.
Why values are readonly
Section titled “Why values are readonly”filters.values is readonly(reactive(...)). Direct assignment is rejected, so there is exactly
one path from a user action to a URL change, and it is greppable. Every change goes through a named
operation.
Lifecycle
Section titled “Lifecycle”When called inside a component or an effect scope, the binding registers onScopeDispose to
unsubscribe — and to dispose the runtime if it created one. A binding given an existing runtime
never disposes it, because it does not own it.
Outside a scope, nothing is registered automatically; hold filters.runtime and dispose it
yourself.
Server rendering
Section titled “Server rendering”@queryweave/vue never touches browser globals, so it is safe to import during server rendering.
What is not safe is creating a browser adapter there — see Nuxt for
request-scoped wiring, or provide a source-backed adapter of your own. Transitions during a server
render are not supported: the response is already being written, and the binding cannot observe a
router that moves without a client. Await filters.settled() before rendering when the model has
an asynchronous refinement.
Edge cases
Section titled “Edge cases”- Two bindings on the same model each create their own runtime unless you pass one. They stay consistent because both read the same adapter, but they are two subscriptions.
fieldon a required key returns the decoded value, which may be a recovered default when the query is invalid.- A required key an invalid snapshot no longer carries is removed from
valuesrather than left at its last value. - An unchanged list keeps its identity across updates to other keys, so a watcher on
filters.values.tagsdoes not fire for a page change. - Reassigning
filters.valuesis a type error. At runtime the binding is a plain object, so the assignment succeeds and simply detaches you from the binding; do not do it.