Transitions
Every change to query state goes through a named operation. There is no setter, no proxy, and no direct mutation of the values a runtime hands you — which is what makes “when does the URL change?” answerable by reading the call site.
update
Section titled “update”Merge a partial patch into the current state.
// current URL: ?search=vue&page=3&sort=priceawait runtime.update({ page: 2 });// written URL: ?search=vue&page=2&sort=price
await runtime.update({ search: "vue", page: 1 });// written URL: ?search=vue&sort=price — page 1 is the defaultKeys you do not mention keep their current value. This is the operation you want most of the time.
replace
Section titled “replace”Swap the entire state. Every managed key is written from the value you pass.
// current URL: ?search=vue&page=3&sort=price&status=activeawait runtime.replace({ search: undefined, page: 1, sort: "created_at", status: "all",});// written URL: /products — every value is absent or equal to its defaultreplace takes a complete state, not a patch, so the compiler tells you when the model gains a
parameter you have not accounted for.
remove
Section titled “remove”Omit keys from the written output, whatever their current value.
// current URL: ?search=vue&page=3&sort=priceawait runtime.remove("search");// written URL: ?page=3&sort=price
await runtime.remove(["search", "page"]);// written URL: ?sort=priceThe keys are dropped from the encoded output, so they disappear from the URL. On the next read they decode as absent — which means a parameter with a default comes back as that default.
Set keys back to their declared defaults.
// current URL: ?search=vue&page=3&sort=priceawait runtime.reset(["page"]); // just these// written URL: ?search=vue&sort=price
await runtime.reset(); // every managed key// written URL: /productsFor a parameter with a default, reset and remove produce the same URL, because a value equal to
its default is omitted. They differ for required parameters, which have no default to reset to.
transaction
Section titled “transaction”Apply several changes as one write.
// current URL: ?search=nuxt&page=4await runtime.transaction((draft) => { draft.search = "vue"; draft.page = 1;});// written URL: ?search=vue — one write and one history entryThe draft is a shallow copy of the current values: replace a nested object rather than mutating it. Mutating the draft is safe precisely because it is not the state — the runtime encodes the result once, writes once, and notifies once. The callback may be asynchronous; while it runs it holds the runtime’s transition queue, so it must not start another transition on the same runtime.
This is the right tool for changes that must not be observable separately. Setting a filter and
resetting pagination in two update calls produces two history entries and two renders; in one
transaction it produces one of each.
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/productsPush or replace
Section titled “Push or replace”Every operation takes a navigation mode, and every runtime has a default:
const runtime = createQueryRuntime({ model, adapter, navigation: "replace" });
await runtime.update({ page: 2 }); // replace — the runtime defaultawait runtime.update({ page: 3 }, { navigation: "push" }); // per call| Mode | History | Back button | Use for |
|---|---|---|---|
push |
adds an entry | returns to the previous state | Deliberate navigation: a filter, a page |
replace |
rewrites the entry | skips past it entirely | Incidental state: a search box, a tab |
The rule of thumb: if a reader would be surprised that Back did not undo it, use push. If they
would be annoyed at pressing Back twenty times to escape a search field, use replace.
The default is push when the runtime does not say otherwise.
Ordering and concurrency
Section titled “Ordering and concurrency”Transitions on one runtime run one at a time, in the order you call them, and each starts from the state the previous one produced. That holds even when the adapter is asynchronous — Vue Router’s is — and even when you do not await:
// Neither write is lost; the second is applied on top of the first.void runtime.update({ search: "vue" });void runtime.update({ page: 2 });// ?search=vue&page=2Without a throttle window, two rapid calls are still two writes and, in push mode, two history
entries. Grouping changes that belong together is what transaction is for; collapsing a burst of
independent calls is what the window below is for.
Throttling
Section titled “Throttling”A runtime can bound how often it writes:
const runtime = createQueryRuntime({ model, adapter, throttle: 300, // milliseconds between writes});The first transition of a burst is written at once. Transitions that arrive while that write is in progress, or before the window that started with it closes, are held; when it closes they are written together — applied in call order on the settled state, encoded once, navigated once, notified once. A burst of keystrokes becomes two writes: the first character and the final term.
Held transitions compose exactly as they would one by one, so a remove followed by an update of
the same key writes the update. The write uses push if any held transition asked for it, because
a transition that wanted a history entry does not lose it by sharing a write with ones that did not.
Every held transition resolves with the result of the write it took part in: the same outcome,
the same reason, and the same output. A held transaction whose mutator throws rejects alone;
an environment error rejects every transition in the write. A write that turns out unchanged
opens no window.
The window belongs to the runtime rather than to a call, because the limit it protects is the environment’s: WebKit refuses more than about a hundred history writes in ten seconds. There is no per-call override and no debounce. A quiet-period delay stays a call-site concern, and for a search field the throttle is usually what you wanted anyway; see Search.
Cancellation
Section titled “Cancellation”Every operation accepts an AbortSignal:
const controller = new AbortController();
void runtime.update({ search: "vu" }, { signal: controller.signal });controller.abort(); // the transition has not applied its change yet
const result = await runtime.update({ search: "vue" });// the earlier transition resolves with outcome "cancelled"; this one is writtenA transition whose signal has aborted by the time it would apply its change resolves cancelled,
with the signal’s reason and an empty output, and touches nothing. A transition that has
started applying — its transaction mutator is running — completes whatever the signal does
afterwards, because a half-applied change has no honest outcome.
Disposing a runtime cancels the transitions it still holds or has queued, each with the snapshot it would have started from; one that is already writing completes and reports its real outcome. A transition issued after disposal rejects, as before.
Outcomes
Section titled “Outcomes”Every transition resolves with an outcome:
| Outcome | Meaning |
|---|---|
committed |
The environment now holds the written query. |
unchanged |
The output already matched the environment; nothing was written or notified. |
refused |
The environment declined — a router guard returned false — and is unchanged. |
redirected |
The environment accepted the write but ended somewhere else. |
cancelled |
The transition was abandoned before it applied its change: its signal aborted, or the runtime was disposed. |
const result = await runtime.update({ page: 9 });
if (result.outcome === "refused") { report(result.reason); // Vue Router's NavigationFailure, for that adapter}result.snapshot is the settled state after the transition whatever its outcome, so after a
refusal it still reflects what the URL holds. Adapters that cannot refuse — the browser and memory
adapters — always report committed or unchanged.
Failure
Section titled “Failure”A transition does not throw when the environment refuses; it resolves with the outcome above. It does reject for two kinds of error:
- a programming error — a transition on a disposed runtime, or a
transactionmutator that throws; - an environment error — the browser rejecting a
pushState, or a router guard throwing.
A listener that throws does not stop the other listeners; the error is rethrown after all of them ran, to the transition that triggered the notification.