valtio-query
v0.0.4
Published
Valtio bridge for @tanstack/query-core. Mirror query/infinite/mutation observers into reactive proxies, snapshot with React hooks, drive optimistic updates with Immer drafts.
Maintainers
Readme
valtio-query
A tiny bridge between valtio and @tanstack/query-core. Mirrors query / infinite-query / mutation observers into valtio proxies; pair with
useSnapshot(or your own subscription) to render. Designed for React Native + valtio class-store apps that already buy into Tanstack's caching/invalidation/retry behavior but don't want@tanstack/react-query's hook-per-query model.
- Bridge, not framework. The whole library is ~600 LOC of glue.
@tanstack/query-coredoes the caching, deduping, retries, focus refetch, GC, optimistic snapshot/rollback; valtio does the reactivity. Bundle measured againstdist/excludes every peer. - Class-store-shaped. Built around the pattern
class FooStore { constructor(signal: AbortSignal, ...) { this.data = query(client, signal, opts); } }. One signal disposes the whole tree. The React hookuseScopedStore(Class, ...args)owns the controller. - Type-safe end to end.
queryOptions({ queryKey, queryFn })brands the key with@tanstack/query-core'sDataTag<T>soclient.getQueryData,client.setQueryData,patch(...), and the observer wrappers all inferTfrom the same definition. - Optimistic updates with Immer drafts.
mutation({ onMutate(input, { patch, patchEach }) { … } })— callpatch(def-or-key, recipe)for exact-key writes andpatchEach(prefix, recipe)for prefix-match writes. The library snapshots the affected entries, applies recipes via Immer'sproduce, runs the mutator, restores on throw, invalidates on settle. No manual rollback boilerplate. - Pluggable error reporter. One function passed to
createQueryClient({ reporter }). Per-observer dedup: refetch loops don't spam Sentry with the same error. - No singletons.
createQueryClient(opts)returns a plainQueryClient; observers take it as a parameter. Multiple clients per process (tests, SSR shards) just work.
Install
pnpm add valtio-query @tanstack/query-core valtio immer
# react peer is optional — drop it if you're using these stores
# without the React snapshot hooks (e.g. plain DOM, RN with vanilla
# valtio subscriptions).Peers: @tanstack/query-core ^5, valtio ^2, immer ^10, react
^18 || ^19 (optional, for valtio-query/react).
Quick start
// query-client.ts
import { createQueryClient } from "valtio-query";
export const client = createQueryClient({
defaultOptions: { queries: { staleTime: 30_000, gcTime: 5 * 60_000, retry: 1 } },
reporter: (err, ctx) => {
// Wire to Sentry / Datadog / console — fires once per distinct error.
console.error(`[query/${ctx.kind}] ${JSON.stringify(ctx.queryKey)}`, err);
},
});// queries.ts — queries AND mutations for a resource, defined together.
// Each factory binds key + fetcher (or `fn`) + value type in one place;
// every consumer downstream infers `T` from the same definition.
import { mutationOptions, prefixMatcher, queryOptions } from "valtio-query";
interface User {
id: string;
name: string;
}
export const userQuery = (id: string) =>
queryOptions({
queryKey: ["users", "detail", id],
queryFn: async (): Promise<User> => (await fetch(`/api/user/${id}`)).json(),
staleTime: 10_000,
});
export const usersListQuery = () =>
queryOptions({
queryKey: ["users", "list"],
queryFn: async (): Promise<User[]> => (await fetch("/api/users")).json(),
});
// Rename lives next to the queries it patches — its optimistic recipes
// reach `userQuery(id).queryKey` / `usersListQuery().queryKey` from the
// same file, so a rename of any key shape moves every consumer with it.
// `ctx.patch(def.queryKey, …)` infers each draft type from the def's
// branded queryKey, no manual <User> at the call site.
export const renameUserMutation = (id: string) =>
mutationOptions({
fn: async (name: string): Promise<User> =>
(await fetch(`/api/user/${id}`, { method: "PATCH", body: JSON.stringify({ name }) })).json(),
invalidates: () => [prefixMatcher(["users"])],
onMutate: (name, { patch }) => {
patch(userQuery(id).queryKey, (draft) => {
draft.name = name;
});
patch(usersListQuery().queryKey, (draft) => {
const u = draft.find((x) => x.id === id);
if (u) u.name = name;
});
},
});// user-store.ts — one class, one signal. The VM holds queries +
// mutation + view state + derived getters; the component reads it all
// off a single snapshot.
import { listQuery, META, mutation, query } from "valtio-query";
import { client } from "./query-client.ts";
import { renameUserMutation, userQuery, usersListQuery } from "./queries.ts";
export class UserStore {
user;
users;
rename;
constructor(signal: AbortSignal, id: string) {
this.user = query(client, signal, userQuery(id));
this.users = listQuery(client, signal, usersListQuery());
this.rename = mutation(client, signal, renameUserMutation(id));
}
// Derived/meta accessors live on the VM — valtio tracks reads through
// `useSnapshot(vm)` so the template stays declarative.
get userMeta() {
return this.user[META];
}
get renameMeta() {
return this.rename[META];
}
}// UserScreen.tsx — React glue. One snapshot, no per-field hook
// destructuring; everything the template needs is a field/getter on
// the VM.
import { useScopedStore } from "valtio-query/react";
import { useSnapshot } from "valtio";
import { UserStore } from "./user-store.ts";
export function UserScreen({ id }: { id: string }) {
const vm = useScopedStore(UserStore, id);
const s = useSnapshot(vm);
if (s.userMeta.isFetching && !s.user.id) return <p>Loading…</p>;
if (s.userMeta.error) return <p>{String(s.userMeta.error)}</p>;
return (
<article>
<h1>{s.user.name}</h1>
<button onClick={() => vm.rename.run("renamed")}>rename</button>
<ul>
{s.users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
</article>
);
}API
Bridge (valtio-query)
createQueryClient(opts)— returns aQueryClient.optsextendsQueryClientConfigwith one extra field,reporter.query(client, signal, opts)— single-object query. Returns a proxy with the result fields mirrored in. Readsstore.user.nameare reactive.valueQuery(client, signal, opts)— same butPartial<T>. Use when the queryFn can return null/incomplete data.listQuery(client, signal, opts)/queryList(...)— array query. Returns a proxyreadonly T[]spliced on each result.infiniteQuery(client, signal, opts)— wrapsInfiniteQueryObserver. Returns a flattenedT[]proxy plus a meta withpages,pageParams,fetchNextPage, etc.mutation(client, signal, config)— wrapsMutationObserver.config.onMutate(input, { patch, patchEach })opens optimistic edits;run(input)snapshots affected entries, applies the patches, awaitsfn, restores on throw, invalidates on settle. Patches against a brandeddef.queryKeyinfer the draft type automatically.queryOptions(...)/infiniteQueryOptions(...)/mutationOptions(...)— pure-identity helpers that type the args at definition time. Brand thequeryKeywithDataTag<T>(for queries) so consumers of the key inferT; pinI/R(for mutations) fromfn.patch(target, recipe)— Immer-recipe optimistic patch.targetis a branded key, a raw key, or aMatcher. Use it directly for standalone optimistic flows; insideonMutateprefer the typedctx.patch/ctx.patchEach(same recipe shape, inference for free).runWithOptimism({ client, patches, invalidateMatchers, run })— the enginemutation()uses internally; exposed for standalone optimistic flows outside the mutation wrapper.keyMatcher(key)/prefixMatcher(prefix)— JSON-structural query predicates. Use to buildinvalidates(input)returns.createScopedStore(factory)—{ store, dispose }pair for imperative (non-React) callers.lazySingleton(factory)— module-level singleton with a single forever-lived AbortSignal.toError(unknown)— coerce a thrown value to anError, preserving.message/.codefrom loose objects (SupabasePostgrestError, fetch wrappers).queryMeta(field)— pull the meta proxy off a tagged value.
React hooks (valtio-query/react)
useScopedStore(Class, ...args)— mount a class store for the component's lifetime. The hook constructsnew Class(signal, ...args), wraps inproxy(...), disposes on unmount. Ifargschange identity and the class implementsupdateArgs(...args), the instance is updated in place; otherwise it's torn down and rebuilt.useSingleton(store)— alias foruseSnapshot. Cosmetic — makes "I'm reading a module-level singleton" obvious at the call site.useQuery(field)/useQueryList(field)/useInfiniteQuery(field)— typed snapshot helpers. Each returns{ data | items, meta }so the call site doesn't have to remember which valtio symbol carries the meta proxy.
Types
Tagged<T, M>—T & { readonly [META]: M }. The observer wrappers return tagged proxies; pullMoff viaqueryMeta()or the React snapshot hooks.QueryMeta,InfiniteQueryMeta<T, P>,MutationMeta<I, R>— reactive status/error/isFetching plus per-shape extras (fetchNextPage,reset, etc.).QueryErrorReporter,QueryErrorKind,QueryErrorContext— reporter signature:(err, { kind, queryKey }) => void.
Optimistic updates
Pass onMutate(input, { patch, patchEach }) to mutation() and call
the helpers inside the body — they collect Immer recipes that the
library applies before the mutator runs:
mutation({
client,
signal,
fn: (input) => api.rename(input.id, input.name),
invalidates: () => [prefixMatcher(["users"])],
onMutate: ({ id, name }, { patch, patchEach }) => {
// Exact-key write: branded queryKey types the draft for free.
patch(userQuery(id).queryKey, (draft) => {
draft.name = name;
});
// Prefix write: every cache entry under `["users","list"]`.
patchEach<User[]>(["users", "list"], (draft) => {
for (const u of draft) if (u.id === id) u.name = name;
});
},
});What run(input) does, in order:
- Call
onMutate(input, ctx)to collect patches viactx.patch/ctx.patchEach. - Cancel any in-flight fetches against the affected keys (so a stale server response can't clobber the optimistic write).
- Snapshot the affected entries.
- Apply the patches via Immer's
produce. - Run the mutator (
fn(input)). - On throw: restore snapshots. On any settle: invalidate matchers so the server response can overwrite.
ctx.patch accepts a DataTag<...>-branded key (recommended — full
inference), a raw readonly unknown[] (specify T at the call
site), or a Matcher (same). If the cache has no entry yet (or holds
a primitive / non-cloneable), produce is skipped and the patch is
a no-op — onMutate can't crash a mutation.
ctx.patchEach is the prefix-match variant: matches every entry
whose key starts with the given segments. Useful for paginated lists
sharded across multiple cache slots, or when one mutation should
ripple across a family of queries.
valtio-query/lite — no query-core peer
A parallel implementation that drops the @tanstack/query-core peer dep
entirely. The cache is a valtio proxy underneath; the same observers,
options helpers, optimistic patches, and React hooks all behave
identically. Drop-in import path swap:
// Same surface, no query-core:
import {
createQueryClient,
query,
infiniteQuery,
mutation,
queryOptions,
infiniteQueryOptions,
mutationOptions,
patch,
prefixMatcher,
keyMatcher,
} from "valtio-query/lite";
import { useScopedStore, useQuery, useMutation } from "valtio-query/react";The same 60-test spec runs against both implementations via vitest
projects (core, lite-root, lite-entries) — three out of three
green minus the 3 hydration tests that depend on query-core's
dehydrate/hydrate.
Two cache backings, opt in via createQueryClient({ backing }):
"root"(default) — oneproxy({ entries: {…} })at the top, every entry an auto-wrapped sub-proxy. Simpler model, fewer allocations on first subscribe."entries"— each entry is its own standaloneproxy({…})in a plainMap. Faster writes and reads under load because valtio's per-proxy version counter doesn't fan out across siblings.
A microbench (node scripts/bench.mjs, N=1000, median μs/op):
| Operation | core (query-core) | lite (root) | lite (entries) |
| -------------------------- | ----------------: | ----------: | -------------: |
| setQueryData | ~1.3 μs/op | ~11.8 μs/op | ~6.8 μs/op |
| getQueryData | ~0.4 μs/op | ~0.3 μs/op | ~0.3 μs/op |
| invalidate(prefix) | ~1.5 μs/op | ~9.2 μs/op | ~7.7 μs/op |
| Observer subscribe + fetch | ~16 μs/op | ~24 μs/op | ~23 μs/op |
Reads are a wash. Writes and prefix-invalidate are dominated by
valtio's proxy set / delete traps under the lite variants —
query-core's hand-tuned Map storage is faster per op, but the lite cost
is still well under any realistic per-frame budget (1000 writes for ~10
ms even on the slowest backing). The per-entry backing is consistently
faster than root: smaller version-bump scope on each write.
Choose lite when
- You don't already use
@tanstack/query-corefor anything else and want to drop the peer dep. Total bundle savings: lite is 4.7 KB gz vs core's 2.5 KB gz, but core requires query-core which is itself ~7 KB gz, so the all-in goes from ~9.5 KB → 4.7 KB. - You want the cache backing to be plain valtio so devtools, debugging, and structural inspection are uniform with the rest of your store layer.
Stay on core when
- You already have query-core (you're using
@tanstack/react-queryalongside, or interop with another query-core consumer). - You need query-core features that lite hasn't reimplemented:
dehydrate/hydrate, focus/online managers, query-level retry predicates (shouldRetry), structural sharing.
Bundle size
| Number | Meaning |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| eager | dist/index.js + every chunk it imports synchronously, gzipped. Worst case a bundler that does no tree-shaking would ship. Peer deps (react, valtio, @tanstack/query-core, immer) are external. |
| consumer | Synthetic probe importing only createQueryClient + query + mutation + queryOptions + keyMatcher, built with Vite + the same oxc minifier the lib uses, against dist/. With sideEffects: false, unused exports tree-shake out. This is what a real consumer ships. |
Budgets are enforced in CI via scripts/check-bundle-size.mjs. A
refactor can't drift them silently.
What's NOT in here
This library is a thin bridge. If you want it, it's in
@tanstack/query-core (which you depend on anyway) or in your own
code:
- No retry policies. Set
defaultOptions.queries.retryoncreateQueryClientlike normal — query-core handles it. - No focus/online refetch.
focusManager/onlineManagership with query-core; configure them on app boot. React Native'sAppState+NetInfowork out of the box. - No SSR helpers.
dehydrate/hydratealready exist on@tanstack/query-core. Call them. - No DevTools. Iterate
client.getQueryCache().getAll()yourself if you need a debug view. - No
useQueries-style parallel composer. Composequery(...)fields inside the same store; they all share the signal.
Releasing
pnpm run release patch # 0.1.0 -> 0.1.1
pnpm run release minor # 0.1.0 -> 0.2.0
pnpm run release prerelease # 0.1.0 -> 0.1.1-0
pnpm run release 0.2.0-beta.0 # explicitscripts/deploy.mjs enforces a clean tree on master/main, bumps
package.json, runs the full check pipeline via
prepublishOnly, publishes, commits the bump, and tags the release.
Push afterward:
git push origin main --follow-tagsLicense
MIT © Daniel Staudigel
