npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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-core does the caching, deduping, retries, focus refetch, GC, optimistic snapshot/rollback; valtio does the reactivity. Bundle measured against dist/ 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 hook useScopedStore(Class, ...args) owns the controller.
  • Type-safe end to end. queryOptions({ queryKey, queryFn }) brands the key with @tanstack/query-core's DataTag<T> so client.getQueryData, client.setQueryData, patch(...), and the observer wrappers all infer T from the same definition.
  • Optimistic updates with Immer drafts. mutation({ onMutate(input, { patch, patchEach }) { … } }) — call patch(def-or-key, recipe) for exact-key writes and patchEach(prefix, recipe) for prefix-match writes. The library snapshots the affected entries, applies recipes via Immer's produce, 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 plain QueryClient; 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 a QueryClient. opts extends QueryClientConfig with one extra field, reporter.
  • query(client, signal, opts) — single-object query. Returns a proxy with the result fields mirrored in. Reads store.user.name are reactive.
  • valueQuery(client, signal, opts) — same but Partial<T>. Use when the queryFn can return null/incomplete data.
  • listQuery(client, signal, opts) / queryList(...) — array query. Returns a proxy readonly T[] spliced on each result.
  • infiniteQuery(client, signal, opts) — wraps InfiniteQueryObserver. Returns a flattened T[] proxy plus a meta with pages, pageParams, fetchNextPage, etc.
  • mutation(client, signal, config) — wraps MutationObserver. config.onMutate(input, { patch, patchEach }) opens optimistic edits; run(input) snapshots affected entries, applies the patches, awaits fn, restores on throw, invalidates on settle. Patches against a branded def.queryKey infer the draft type automatically.
  • queryOptions(...) / infiniteQueryOptions(...) / mutationOptions(...) — pure-identity helpers that type the args at definition time. Brand the queryKey with DataTag<T> (for queries) so consumers of the key infer T; pin I / R (for mutations) from fn.
  • patch(target, recipe) — Immer-recipe optimistic patch. target is a branded key, a raw key, or a Matcher. Use it directly for standalone optimistic flows; inside onMutate prefer the typed ctx.patch / ctx.patchEach (same recipe shape, inference for free).
  • runWithOptimism({ client, patches, invalidateMatchers, run }) — the engine mutation() uses internally; exposed for standalone optimistic flows outside the mutation wrapper.
  • keyMatcher(key) / prefixMatcher(prefix) — JSON-structural query predicates. Use to build invalidates(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 an Error, preserving .message / .code from loose objects (Supabase PostgrestError, 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 constructs new Class(signal, ...args), wraps in proxy(...), disposes on unmount. If args change identity and the class implements updateArgs(...args), the instance is updated in place; otherwise it's torn down and rebuilt.
  • useSingleton(store) — alias for useSnapshot. 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; pull M off via queryMeta() 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:

  1. Call onMutate(input, ctx) to collect patches via ctx.patch / ctx.patchEach.
  2. Cancel any in-flight fetches against the affected keys (so a stale server response can't clobber the optimistic write).
  3. Snapshot the affected entries.
  4. Apply the patches via Immer's produce.
  5. Run the mutator (fn(input)).
  6. 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) — one proxy({ entries: {…} }) at the top, every entry an auto-wrapped sub-proxy. Simpler model, fewer allocations on first subscribe.
  • "entries" — each entry is its own standalone proxy({…}) in a plain Map. 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-core for 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-query alongside, 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.retry on createQueryClient like normal — query-core handles it.
  • No focus/online refetch. focusManager / onlineManager ship with query-core; configure them on app boot. React Native's AppState + NetInfo work out of the box.
  • No SSR helpers. dehydrate/hydrate already 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. Compose query(...) 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 # explicit

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

License

MIT © Daniel Staudigel