Changelog
0.1.0 — Unreleased (initial publish candidate)
This package was previously prototyped against a personal project and never released. The 0.1.0 release candidate is a near-rewrite that fixes correctness bugs in the prototype, marks the package client-only, and ships the test suite that wasn’t there before.
Fixed
- Five public actions stack-overflowed on first call.
queryAtom.clearCache(),queryAtom.getCacheStats(),queryAtom.garbageCollect(),queryAtom.limitCacheSize(),queryAtom.setupAutoGC()each delegated to a same-named import viaexport function X(...) { return X(...); }— the local export shadowed the import and the function called itself recursively. Now the imports are aliased (e.g.engineClearCache) and the wrappers delegate correctly. invalidatematched siblings whose hash shared a string prefix.invalidate({ queryKey: ["users", 1] })falsely matched["users", 10],["users", 100], etc., because the implementation used a rawkey.startsWith(...)on the pipe-joined hash. Replaced withmatchesQueryPrefix: requires either exact equality or a JSON-array boundary (,) after the prefix, so["users",1]matches["users",1]and["users",1,...]but never["users",10].- Concurrent same-key fetches issued duplicate network calls. Three components mounting
useQuery({ queryKey: ["users"], queryFn })in the same render triggered three fetches. The “already loading” guard insideloadQueryread from a closed-over clone of the query — all three calls sawstate: "idle". Now aninFlight: Map<hashKey, Promise>deduplicates: concurrent calls join the existing promise. useQuerymutated global state inside theuseStateinitializer. Setting up the initial query record inside a render-phase initializer broke Strict Mode (created twice) and Suspense retries. The query is now created in auseEffect; the hook subscribes viauseSyncExternalStoreand returns a stable per-key placeholder on the first paint.use*hooks returned closure variables instead of subscribed state.const [, setX] = useState(query[changeType]); return query[changeType]— only re-rendered by accident on the next render. Replaced with properuseSyncExternalStoreslices.onQueryChangemissed first-create and destroy transitions. The previous gateoldQuery && newer.lastModified > oldQuery.lastModifiedrequired a previous entry, so the initialundefined → loading → successarc and theQuery → undefineddestroy never fired the callback. Now fires whenever the slice reference changes.- Hash collisions in
parseQueryKey. The pipe-joined serializer collided on["users", "1|2"]vs.["users", 1, 2], and was insensitive to object-key ordering when it shouldn’t have been. Replaced with canonical JSON (sorted object keys); collisions are gone, ordering differences canonicalize, and partial invalidation gains a clean boundary character (,). - Stale
queryFnclosure on refetch. The cached query stored the very firstqueryFnit ever saw. If consumers re-rendered with a new closure capturing fresh props/state, refetches still ran the original — producing stale results from a manualrefetchQuery. Now the hook registers the latestqueryFnper hash in a registry; refetches read from there. isLoadingwas conflated withisFetching. Background refetches flashed loading spinners that should only have shown for first fetches. Split into two booleans; first-fetch UI usesisLoading, refresh indicators useisFetching.removeAllwas misleadingly named — it didn’t mutate. Previously returned a filtered copy without committing. Behavior preserved (still pure) but the surrounding helpers (remove,removeByIndex,push,pop,unshift,shift,replace,clear,sort,reverse) all properly mutate. New array-helper layer is uniformly immutable internally;removeAllis no longer exported as it’s the only non-mutating sibling.- Cache subscribers woke up on every update of every query.
useQuerysubscribed toqueryAtom.onChange(whole atom). With 50 mounted queries, every refetch fired 50 callbacks. Per-key subscriptions now wake only the consumers of the specific slice that changed. destroyQuerydidn’t abort in-flight fetches or clean up the latest-queryFnregistry. Now does both.garbageCollectevicted actively-used queries. UsedlastModified(set on refetch) instead oflastAccessed(set on observer attach). A query read by 10 components but never refetched got GC’d. Now useslastAccessedAND observer count: only zero-observer queries are eligible for eviction.
Added
useInfiniteQuery. Paginated/cursor queries withfetchNextPage(),hasNextPage,isFetchingNextPage. Cached as{ pages: TPage[]; pageParams: TPageParam[] }so invalidation, GC, and refetch-on-focus work for free;getNextPageParamcomputes the cursor for the next fetch.useSuspenseQuery. Thin Suspense wrapper overuseQuery— throws a promise while loading, throws the error when failed, returns the query withdata: Twhen settled. Initializes the cache synchronously during render so the fetch actually fires even when the component suspends from first render.useMutation. Imperative side-effect hook withmutate/mutateAsync/reset/data/error/variables/status/isPending/isError/isSuccess/isIdle, plusonMutate/onSuccess/onError/onSettledlifecycle. A secondmutateaborts the first; unmount aborts the in-flight call.seedQuery+<HydrateQueries>. SSR integration via the framework loader: pre-populate the cache with data fetched by your framework (Next.js server component, Remixloader, TanStack Startloader). Consumers see seeded data on first paint with no flash and no refetch as long as it’s fresh.AbortSignalpropagation. EveryqueryFnandmutationFnreceives{ signal }. A new fetch for the same key aborts the previous one;destroyQueryaborts; mutations abort on second-call or unmount.- Reference-counted GC.
useQueryattaches/detaches observers.garbageCollectonly evicts queries with zero observers AND a stalelastAccessed. Auto-started on the firstuseQuery— no manualsetupAutoGC()required. - Granular subscription hooks.
useLoadChange,useErrorChange,useDataChange,useQueryChange(key, "isFetching"). Each subscribes to a single field; re-renders only when that field changes. - Client-only enforcement. Every file
"use client". Package exports map:"react-server": null. Bundlers refuse to load this from a React Server Component. - Test suite. 41 unit tests across
hash,actions,hooks,mutation. Specifically guards against regression on: self-recursion, segment-boundary invalidation, hash collisions, concurrent fetch dedup, Strict Mode double-create, queryFn freshness, abort behavior. - CI. Node 18/20/22 × Ubuntu, Node 20 × Windows, React 18 + React 19.
- AI kit.
llms.txt,llms-full.txt,skills/(README,overview,queries,mutations,cache,list-helpers,ssr). - README. Marketing-style with framework integration examples and a migration-from-TanStack-Query map.
Changed (breaking — though no prior release to break)
isLoadingis now first-fetch only. Background refetches setisFetching: truewithout touchingisLoading. Any UI that usedisLoadingfor background spinners should switch toisFetching.Query.dataisT | undefined, notT. The runtime always treated it asnullinitially; the type now honestly admits that.queryFnsignature is(ctx: { signal: AbortSignal }) => Promise<T>, not() => Promise<T>. The signal is non-optional in the type but consumers are free to ignore it.Query.onSuccess/onErrorfire with the post-update query, not the pre-update one. The callback receives the latest snapshot via a re-read of the cache afterlastModifiedis bumped.updateQueryData<T>(key, updater)receivesT | undefined(wasT). Reflects that updaters can run before the first fetch resolves.onQueryChangecallback receivesQuery | undefinedfor both arguments. The previous signature assumed both sides were defined.- List helpers no-op on
undefined.queryAtom.push(["users"], item)on a not-yet-loaded query now produces[item]instead of throwing. destroyQuery(key)now aborts in-flight fetches, where the old version left them running and only deleted the cache entry.
Removed
- The five self-recursive wrapper exports (
clearCache,getCacheStats,garbageCollect,limitCacheSize,setupAutoGCfrom the oldquery-actions.ts). The new wrappers delegate correctly to engine implementations. - Pipe-joined hash format. Anything that read raw
hashKeystrings will break — they’re now JSON. UseparseQueryKey(queryKey)to compute a hash if you need one. removeAllfrom the array helpers. It was the only non-mutating sibling and confused callers. UsequeryAtom.updateQueryData(key, old => (old ?? []).filter(x => x !== item))orqueryAtom.remove(key, item).
Tests
41 + 4 infinite + 2 suspense = 47 passing