@cogs/react-query
v0.2.0
Published
The single TanStack Query seam — re-exports the library plus a shared createQueryClient (centralised retry, 401/403 handling) and stable object-key hashing for Kubb-generated hooks
Readme
@cogs/react-query
The single TanStack Query seam. It re-exports the whole of
@tanstack/react-query, plus a shared createQueryClient and stable key
hashing, so every Kubb-generated hook and every app call site imports Query from
one place — one library version, one QueryClient, one retry/auth policy.
Why a seam
Point Kubb's React Query plugin at this package instead of @tanstack/react-query:
// kubb.config.ts
pluginReactQuery({
query: { importPath: '@cogs/react-query' },
mutation: { importPath: '@cogs/react-query' },
client: { importPath: '@cogs/fetch-client' },
})Generated hooks then import useQuery/useMutation/queryOptions from here.
Bump the Query major in one package.json; every generated client moves
together. No two copies of the library fighting over one cache.
createQueryClient
import { QueryClientProvider } from '@cogs/react-query'
import { createQueryClient } from '@cogs/react-query'
const client = createQueryClient({
onUnauthorized: () => router.push('/login'),
onForbidden: () => toast.error('Not allowed'),
})
<QueryClientProvider client={client}>{children}</QueryClientProvider>Centralised policy, identical everywhere:
- Retry — up to 3 by default, but never on a terminal 4xx.
408(timeout) and429(rate limit) are still retried; every other 4xx is not. - Mutations never retry by default — replaying a non-idempotent write risks a duplicate. Opt in per mutation.
- 401 →
onUnauthorized, 403 →onForbidden, everything else →onError. Status is read off the error the transport threw (@cogs/fetch-clientputs it on.status).
hashKey
hashKey / hashKeys stringify a query key with object properties sorted, so
Kubb's object-shaped keys ([{ url, id }]) hash identically regardless of
property order — two structurally-equal keys never split into two cache entries.
Cache determinism helpers
bumpUpdatedAt, cancelAndSnapshot, and rollback centralise the
cancel-in-flight / optimistic-write / rollback-on-error pattern so mutation
hooks don't hand-roll it per call site. See the react-query-cache-determinism
skill for the full pattern (deep-merge vs replace, invalidation strategy).
import { cancelAndSnapshot, rollback, bumpUpdatedAt, useMutation } from '@cogs/react-query'
import type { QueryClient } from '@cogs/react-query'
function useUpdateRecord(queryClient: QueryClient, id: string) {
const detailKey = ['record', id]
const listKey = ['records']
return useMutation({
mutationFn: updateRecord,
onMutate: () =>
// Cancel in-flight fetches and snapshot current data for rollback.
cancelAndSnapshot(queryClient, [detailKey, listKey]),
onSuccess: (data) => {
// Monotonic updatedAt: two same-millisecond writes never tie.
queryClient.setQueryData(detailKey, data, {
updatedAt: bumpUpdatedAt(queryClient, detailKey),
})
},
onError: (_err, _vars, snapshot) => {
if (snapshot) rollback(queryClient, snapshot)
},
})
}cancelAndSnapshot cancels in-flight queries for the given keys and returns a
snapshot of their cached data; rollback restores it — removing an entry that
had no prior data instead of writing it back as undefined, since
setQueryData treats undefined as a no-op.
nextHashKeys
nextHashKeys / nextHashKey / nextHashMergedKeys tag a query key for
Next.js's fetch() next.tags cache-invalidation API. This is a distinct
algorithm from hashKey/hashKeys above (which hash a whole key for devtools
prefix comparison): nextHashKeys hashes each key segment independently.
Wire it into a generated query function:
config.next = { tags: nextHashKeys(queryKey) }so a mutation's revalidateTag() call can target exactly the query keys it
affects.
Devtools
import { ReactQueryDevtools } from '@cogs/react-query/devtools'