@dts/vue-uquery
v0.1.6
Published
Vue micro query — ~4KB gz query/infinite-query/mutation composables for Vue 3, with reactive optimistic updates and SSR hydration.
Maintainers
Readme
@dts/vue-uquery
Vue 3 query, infinite-query, and optimistic mutations in ~2.7 KB gz for typical use. Native Vue reactivity, no adapter layer.
- Tiny. ~2.7 KB gz for a query-only app (measured via a synthetic
consumer bundle against
dist/, not aspirational). ~4.2 KB gz worst-case if you import every named export.sideEffects: falseand a separate@dts/vue-uquery/extrasentry guarantee apps pay only for what they import — verified in CI on every commit. - Vue-native.
useQueryreturns areactive()object backed by refs;<script setup>awaitintegrates with<Suspense>; reactive keys flow throughMaybeRefOrGetter<QueryKey>so arefin a parent's prop reruns the fetch without remounting. - Type-safe end to end.
defineQuery({ key, fetcher })binds the key, fetcher, and value type in one place. The same definition flows throughuseQuery,client.getQueryData,client.patch,client.invalidateQueries— every consumer infers the sameT. - Race-safe optimistic mutations.
client.patch(key)opens a Vue reactive draft; mutate it in place and every liveuseQueryupdates instantly. Concurrent fetches, entry evictions, and sibling patches can't silently corrupt the cache:commit/revertdetect overlap and fall back to invalidate-from-server when local rollback can't be trusted. Pure all-commit overlaps pay nothing. - Strict
QueryKey. Compile-time-enforced JSON-shaped keys (string | number | nested arrays/objects).Date,Map,Set,BigInt,Symbol,null,undefinedare rejected at the type level — no more silent hash collisions. - SSR built in.
dehydrate(client)/hydrate(client, payload)are free functions exported from the main entry; tree-shake out cleanly for apps that don't do SSR.
A tiny alternative to @tanstack/vue-query if you're on Vue 3 and want
the API surface area, not the framework underneath it.
Install
pnpm add @dts/vue-uquery
# or: npm install @dts/vue-uquery / yarn add @dts/vue-uqueryPeer dep: vue ^3.5.
Quick start
// main.ts
import { createApp } from "vue";
import { createQueryClient } from "@dts/vue-uquery";
import App from "./App.vue";
const client = createQueryClient({ staleTime: 5_000 });
createApp(App).use(client).mount("#app");// queries.ts — define once, use everywhere. The key, fetcher, and result
// type are bound together; `T` flows automatically through every consumer.
import { defineQuery, type MaybeRefOrGetter, toValue } from "@dts/vue-uquery";
interface User {
id: string;
name: string;
}
export const userQuery = (id: MaybeRefOrGetter<string>) =>
defineQuery({
key: () => ["users", "detail", toValue(id)],
fetcher: async ({ signal }): Promise<User> =>
(await fetch(`/api/user/${toValue(id)}`, { signal })).json(),
staleTime: 10_000,
});<!-- User.vue — mount the parent in <Suspense> so the await resolves -->
<script setup lang="ts">
import { toRef } from "vue";
import { useQuery } from "@dts/vue-uquery";
import { userQuery } from "./queries";
const props = defineProps<{ id: string }>();
// Pass the ref, not props.id directly — the key is reactive and the
// fetch re-fires when the parent changes the id prop.
const q = await useQuery(userQuery(toRef(props, "id")));
</script>
<template>
<p v-if="q.isLoading">Loading…</p>
<p v-else-if="q.error">{{ String(q.error) }}</p>
<article v-else>{{ q.data.name }}</article>
</template><!-- Parent.vue — Suspense lets the User.vue await resolve before render -->
<Suspense>
<User :id="userId" />
<template #fallback><p>Loading…</p></template>
</Suspense>Don't destructure. Returns are plain reactive objects (Vue reactive()
with getters); const { data } = q snapshots and breaks reactivity. Use
q.data directly.
API
Core (always loaded)
createQueryClient(opts?)— make a client.opts.staleTime,opts.gcTime.useQueryClient()— resolves the active client viainject. Throws if no client was installed viaapp.use(client)or__setGlobalClient(...)for imperative/test use; the library refuses to auto-create a fallback because that silently splits state across an unprovided client and an injected one when anawaitboundary loses the component instance.defineQuery({ key, fetcher, ... })/defineInfiniteQuery({ ... })— bind a key, fetcher, and result type together. Pass the returned def touseQuery/useInfiniteQueryand the client's imperative methods;Tis inferred once and threaded through every consumer. Recommended over raw keys for anything beyond a one-off.client.getQueryData(def-or-key)/client.setQueryData(def-or-key, updater).client.patch<T>(def-or-key)/client.patchEach<T>(prefix, fn)— see the Optimistic updates section below.client.invalidateQueries(def-or-key?, { exact? })/client.removeQueries(...).client.fetchQuery(def | { key, fetcher })— imperative one-shot.dehydrate(client)/hydrate(client, payload)— free functions; see SSR below. Tree-shake out when not imported.hashKey(key)— sorted-JSON canonical hash for aQueryKey.
QueryKey is string | readonly QueryKeyValue[], where QueryKeyValue is
string | number | nested arrays/objects — strict at the type level. Any
value that doesn't JSON-stringify cleanly (Date, Map, Set, BigInt,
Symbol, null, undefined, functions) is rejected by TypeScript.
Composables
// useQuery
const q = await useQuery<T>({
key, // string | readonly unknown[] | ref/getter of one
fetcher, // (signal) => Promise<T>
enabled?, // ref/getter — gate fetching
staleTime?, // ms before background refetch (default 0)
gcTime?, // ms after last unsubscribe before eviction (default 5min)
meta?, // arbitrary record, surfaced on the cache entry
awaitInitial?, // default true; set false to skip the SSR await
retry?, // number of retries (default 0)
retryDelay?, // ms or (attempt) => ms; default exponential 1s→30s cap
shouldRetry?, // (err, attempt) => boolean
});
// q.data, q.error, q.status, q.isLoading, q.isFetching, q.refetch()
// useInfiniteQuery
const list = await useInfiniteQuery<TPage, TPageParam>({
key, initialPageParam, fetcher: ({ pageParam, signal }) => ...,
getNextPageParam: (lastPage, allPages) => nextOrUndefined,
// + same staleTime/gcTime/meta/enabled/awaitInitial/retry as above
});
// list.pages, list.pageParams, list.hasNextPage, list.fetchNextPage(),
// list.isFetching, list.isFetchingNextPage, list.refetch()
// useMutation — synchronous; no `await` needed.
const m = useMutation<T, V>({
mutator: async (vars) => ...,
scope?, // used by useIsMutating({ scope }) extra
// onMutate receives a `patch` helper that opens a mutable draft on a
// cache key. Every patch taken here is auto-committed on success and
// auto-reverted on rejection — no `onError` boilerplate needed for the
// common "snapshot + rollback" case.
onMutate?: (vars, { patch, patchEach }) => ...,
onSuccess?, onError?, onSettled?,
});
// m.data, m.error, m.status ('idle'|'pending'|'success'|'error'), m.reset()
// m.mutate(v): Promise<T> — rejects on mutator/onError throws; catch or
// ignore depending on whether the UI tracks status.Invalidation
client.invalidateQueries(def-or-key?, { exact? }) marks matching entries
stale and triggers a refetch on those that have an active observer. Use it
when an event happens that makes cached data no longer trustworthy —
typically a mutation with server-side side effects you can't compute
locally, a manual "refresh" button, or focus/reconnect.
client.invalidateQueries(["users"]); // every "users.*" entry
client.invalidateQueries(["users", "detail", "1"], { exact: true }); // exact match only
client.invalidateQueries(userQuery(id)); // typed via def
client.invalidateQueries(); // every entryYou usually don't need to invalidate inside useMutation. Patches
opened in onMutate auto-commit on the mutator's success and auto-revert
on its failure; when local rollback would be ambiguous (concurrent fetch,
sibling patch, evicted entry) the patch system already hands off to the
server (mark stale + refetch). Reach for an explicit onSettled: () =>
client.invalidateQueries(...) only when the mutation triggered server-side
state that doesn't map to your optimistic write — derived counts, audit
logs, related resources updated by a webhook.
client.removeQueries(def-or-key?, { exact? }) is the harder hammer: drop
matching entries from the cache entirely. Pair it with logout flows or
"discard everything for this account" buttons.
Optimistic updates
The cache exposes a patch primitive built around Vue's reactivity rather
than React-style snapshot-and-restore. Standalone (e.g. from a button handler
outside useMutation):
const user = client.patch<User>(["users", "detail", id]);
// → Patch<User> | undefined (undefined if the entry isn't loaded yet, or
// holds a primitive / non-cloneable value)
if (user) {
user.draft.name = "New"; // live useQuery views update immediately
try {
await api.updateUser(...);
user.commit(); // freeze the optimistic value in cache (bails
// harmlessly if the cache moved during the
// await — see Race safety below)
} catch (e) {
user.revert(); // roll back to the pre-mutation snapshot, OR
// invalidate-from-server if rollback can't be
// trusted (concurrent fetch / sibling patch)
throw e;
}
}Inside useMutation.onMutate the lifecycle is automated — the helper returns
the draft directly (no Patch wrapper, no manual commit/revert), because
every patch taken in onMutate is auto-committed on success and
auto-reverted on rejection. No onSettled: () => invalidateQueries(...)
boilerplate needed for the common "edit one record, optimistically reflect
in detail + list views" case:
const update = useMutation<User, string>({
mutator: (name) => api.updateUser(id, { name }),
onMutate: (name, { patch, patchEach }) => {
// Pass the def — draft is typed as `User`, no manual <User> needed.
const u = patch(userQuery(id));
if (u) u.name = name;
// Same for `patchEach`: pass the infinite-list def and the draft is
// typed as `{ pages: Page<User>[], pageParams: number[] }`. No raw
// key prefix, no `<{ pages: { items: User[] }[] }>` widening.
patchEach(userListQuery(), (list) => {
for (const page of list.pages)
for (const item of page.items) if (item.id === id) item.name = name;
});
},
// No onSettled / onSuccess / onError needed for the rollback or refetch.
// Add onSuccess only for stuff like router navigation or toast UI; add
// an explicit invalidateQueries only if the server changes data your
// optimistic write didn't cover (counts, audit logs, related entities).
});patch requires the entry to already exist in the cache; non-object values
(primitives) and non-cloneable objects (records with own-property functions)
return undefined so onMutate can't crash a mutation. For cases where you
want to create-or-update, fall back to client.setQueryData(key, value) —
that path doesn't snapshot/auto-revert.
Race safety. commit / revert check that the cache still holds the
proxy they installed. If something else has touched the entry during the
mutator's await (a concurrent fetch, a sibling patch, an eviction), the
settle paths bail or hand off to the server instead of clobbering:
- Two mutations on the same key both succeeding → both values land via the natural stale-bail; no refetch fires.
- Any revert during overlap → mark stale + refetch if observed
(fire-and-forget;
useMutation's lifecycle callbacks fire on the mutator promise, independent of the patch's settle path). - Concurrent fetch lands during an optimistic window → the fresh value is preserved on commit-bail; revert hands off to the server.
The mental model: the patch system handles the rollback/recovery
ambiguity for you. Reserve client.invalidateQueries(...) for the
cases described under Invalidation above — server-side
state your optimistic write can't compute.
Extras — @dts/vue-uquery/extras
Tree-shakable opt-in helpers:
import {
// fetcher wrappers
structuralShare,
withStructuralShare,
// result views
placeholder,
select,
throwOnError,
// client utilities
cancelQueries,
matchQueries,
useIsFetching,
useIsMutating,
seedQuery,
// lifecycle effects
refetchOnFocus,
refetchOnReconnect,
useRefetchInterval,
persist,
useOfflinePause,
} from "@dts/vue-uquery/extras";| Helper | Purpose |
| ------------------------------------------ | -------------------------------------------------------- |
| placeholder(q, { keepPrevious }) | Show prior data during transitions |
| select(q, fn) | Derive a transformed view of q.data |
| throwOnError(q) | Re-throw q.error for an error boundary |
| cancelQueries(client, key?) | Abort in-flight fetches matching a key prefix |
| matchQueries(client, predicate) | Find cache entries by arbitrary predicate |
| useIsFetching(key?) | Reactive count of in-flight queries |
| useIsMutating(scope?) | Reactive count of pending mutations |
| seedQuery(client, key, data, updatedAt?) | Pre-populate the cache |
| withStructuralShare(fetcher, current) | Preserve object identity for unchanged sub-trees |
| refetchOnFocus(client, key?) | Invalidate matching queries on window.focus/visibility |
| refetchOnReconnect(client, key?) | Invalidate matching queries when network returns |
| useRefetchInterval(query, ms) | Reactive polling cadence; 0 stops |
| persist(client, storage, opts?) | Hydrate from + write to localStorage-shaped storage |
| useOfflinePause(client) | Cancel fetches offline; invalidate when back online |
SSR
// server
import { createQueryClient, dehydrate } from "@dts/vue-uquery";
const client = createQueryClient();
await client.fetchQuery(userQuery("1"));
const payload = dehydrate(client); // ship in <script> tag
// client
import { createQueryClient, hydrate } from "@dts/vue-uquery";
const client = createQueryClient({ staleTime: 60_000 });
hydrate(client, payload);
// useQuery for the same key returns immediately without a fetch.dehydrate/hydrate are free functions over the client (not methods on
it) so an app that doesn't SSR never pays for them — sideEffects: false
guarantees the bundler drops them.
Bundle size
Two numbers, two audiences, both measured in CI on every commit:
| Number | Meaning |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ~2.7 KB gz | Typical consumer: a synthetic build importing only createQueryClient, useQuery, useQueryClient against dist/. What your users actually load if you're query-only. |
| ~4.4 KB gz | Worst case if a bundler does no tree-shaking. Every named export from the main entry — define helpers, mutations, infinite, SSR, the patch system, all in. |
scripts/check-bundle-size.mjs builds a synthetic consumer probe with the
same Vite + rolldown the rest of the toolchain uses; the budget is enforced
so a refactor can't drift these numbers without a test failure.
@dts/vue-uquery/extras is a separate entry on the same terms — every
helper you don't import tree-shakes out.
What it doesn't have (and won't)
- No
useQueries— dynamic-array parallel queries. Out of scope for the core budget; a candidate for a future extra. - No DevTools panel. The cache exposes
client.cache.entriesand a reactivecache.versionref, so a devtools layer is implementable in extras, but nothing ships in the box. - No global mutation callbacks. Per-mutation
onSuccess/onError/onSettledonly — for global error logging, wrapuseMutationin your own composable. - No queueing or
mutationKeyscopes. Concurrent mutations on the same key are race-safe (see above), but there's no built-in serialization.
For everything else (retries with backoff, window-focus refetch, polling, placeholder/keepPrevious, structuralSharing, persistence, offline pause, etc.) see the extras subpath.
The general design principle: the core exposes reactive primitives;
everything else is Vue composition. If a feature can be implemented by
watch/computed/reactive over the existing cache + mutation refs, it
belongs in extras or in your own application code — not in the core.
Releasing
The example app and any future workspace package reference the library as
"@dts/vue-uquery": "workspace:^". pnpm rewrites that to the concrete
^X.Y.Z range at publish time, so internal references stay version-free.
To cut a release:
pnpm run release patch # 0.0.1 -> 0.0.2
pnpm run release minor # 0.0.1 -> 0.1.0
pnpm run release prerelease --preid=beta
pnpm run release 0.2.0-alpha.0scripts/deploy.mjs enforces a clean tree on master, bumps package.json,
publishes (running the full check pipeline via prepublishOnly), commits
the bump, and creates an annotated git tag (vX.Y.Z). Pre-release versions
get a matching npm dist-tag so npm install @dts/vue-uquery keeps pointing
at the stable latest. After it succeeds, push:
git push origin master --follow-tagsLicense
MIT © Daniel Staudigel
