@dts/vue-uquery

Vue micro query — ~4KB gz query/infinite-query/mutation composables for Vue 3, with reactive optimistic updates and SSR hydration.
2,539
Install
npm install @dts/vue-uquery
Latest Version:0.1.8
Vue:3
License:MIT
Last Updated:Jul 28, 2026
Links: GitLab  ·  npm
Maintainer: dts

@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: false and a separate @dts/vue-uquery/extras entry guarantee apps pay only for what they import — verified in CI on every commit.
  • Vue-native. useQuery returns a reactive() object backed by refs; <script setup> await integrates with <Suspense>; reactive keys flow through MaybeRefOrGetter<QueryKey> so a ref in 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 through useQuery, client.getQueryData, client.patch, client.invalidateQueries — every consumer infers the same T.
  • Race-safe optimistic mutations. client.patch(key) opens a Vue reactive draft; mutate it in place and every live useQuery updates instantly. Concurrent fetches, entry evictions, and sibling patches can't silently corrupt the cache: commit/revert detect 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, undefined are 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-uquery

Peer 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 via inject. Throws if no client was installed via app.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 an await boundary loses the component instance.
  • defineQuery({ key, fetcher, ... }) / defineInfiniteQuery({ ... }) — bind a key, fetcher, and result type together. Pass the returned def to useQuery / useInfiniteQuery and the client's imperative methods; T is 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 a QueryKey.

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 entry

You 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.entries and a reactive cache.version ref, so a devtools layer is implementable in extras, but nothing ships in the box.
  • No global mutation callbacks. Per-mutation onSuccess / onError / onSettled only — for global error logging, wrap useMutation in your own composable.
  • No queueing or mutationKey scopes. 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.0

scripts/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-tags

License

MIT © Daniel Staudigel

Related Packages

@hebilicious/vue-query-nuxt

A Nuxt module for Vue Query

111,338 110
@peterbud/nuxt-query

Nuxt integration for Tanstack Query

222,156 45
@pinia/colada

The smart data fetching layer for Vue.js

3,048,795 2,240

Version History

Version Released Vue License
0.1.8 ^3.5.0 MIT
0.1.7 ^3.5.0 MIT
0.1.6 ^3.5.0 MIT
0.1.4 ^3.5.0 MIT
0.1.3 ^3.5.0 MIT