@routar/react-query
createQueries(api, options?)
Derives typed queryOptions and mutationOptions factories from a routar API client. The router does not need to be re-passed — createApi stamps it on the client’s $router property and createQueries recovers it. The returned object mirrors the router’s shape — GET endpoints become query accessors, non-GET endpoints become mutation accessors.
import { createQueries } from '@routar/react-query'
import { todoApi } from './todo'
export const todoQuery = createQueries(todoApi)Parameters
| Parameter | Type | Description |
|---|---|---|
api | ApiClientWithRouter (from createApi) | The typed client produced by createApi; carries its source router on $router |
options.key | string | string[] | Override the root key segments (default: derived from router prefix) |
options.defaults | Record<string, QueryOptions | MutationOptions> | Per-endpoint default options keyed by endpoint name. Merged before per-call options (per-call wins). Nested routers supported (the map mirrors the router shape); for mutation endpoints, value is mutation options minus invalidates. |
options.infinite | Record<string, { initialPageParam, getNextPageParam, pageParam }> | Per-endpoint infinite query contract keyed by GET endpoint name. Declares initialPageParam, getNextPageParam, and the routar-specific pageParam builder once so call sites only need base params. Nested routers supported — the map mirrors the router shape. |
Error typing
error on query/mutation results is typed as TanStack’s DefaultError. To narrow it to HttpError across your project, augment TanStack’s Register interface once — no change to createQueries needed:
import type { HttpError } from '@routar/core'
declare module '@tanstack/react-query' {
interface Register { defaultError: HttpError }
}Query accessors (GET endpoints)
Each query accessor is callable and returns a TanStack queryOptions object:
// signature: (params?, queryOptions?) => queryOptions
todoQuery.getList()
todoQuery.getList({ query: { done: true } }, { staleTime: 60_000 })
todoQuery.getDetail({ path: { id: '1' } }).queryKey(params?) — returns the branded query key for this accessor:
todoQuery.getList.queryKey() // ["todos", "getList"]
todoQuery.getDetail.queryKey({ path: { id: '1' } }) // ["todos", "getDetail", { path: { id: "1" } }]Infinite query accessor (GET endpoints — .infinite)
Every query accessor has an .infinite callable that returns a TanStack infiniteQueryOptions object for use with useInfiniteQuery, useSuspenseInfiniteQuery, or prefetchInfiniteQuery.
Declare the pagination contract once in createQueries({ infinite }). Call sites then only supply base params (page-independent):
// Declare the contract in createQueries — nested routers supported (the map mirrors the router shape)
export const todoQuery = createQueries(todoApi, {
infinite: {
getList: {
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) =>
lastPage.length === 10 ? allPages.length + 1 : undefined,
pageParam: (page) => ({ query: { _page: page } }), // routar-specific
},
},
})
// Call site — base params only; contract comes from config
todoQuery.getList.infinite({ query: { _limit: 10 } })
// SSR prefetch
queryClient.prefetchInfiniteQuery(todoQuery.getList.infinite())Signature: (params?, override?) => infiniteQueryOptions
params— base routar request (page-independent):{ path?, query?, body? }. Optional; omit for no-param endpoints.override— optional partial of the contract that merges over the configured one (call wins). You can also pass the full contract here for ad-hoc use without acreateQueriesconfig entry, but declaring it increateQueriesis the recommended pattern.
If an endpoint has no infinite config and the full contract is not supplied via override, the library throws a clear runtime error at call time.
Contract fields (declared in createQueries({ infinite: { <endpoint>: { ... } } }) or supplied as override):
| Field | Type | Description |
|---|---|---|
initialPageParam | number | Starting page param value. Required by TanStack. Page param is typed as number; for cursor (string) pagination, cast at the call site. |
getNextPageParam | (lastPage, allPages) => number | undefined | Returns the next page param, or undefined to stop. Required by TanStack. |
pageParam | (page: number) => DeepPartial<TRequest> | routar-specific. Maps the current page param to a partial request object. The return is deep-merged into base params before the routar client is called. Replaces queryFn — do not pass queryFn. |
getPreviousPageParam | (firstPage, allPages) => number | undefined | Optional TanStack native option. |
maxPages | number | Optional TanStack native option. |
Any other infiniteQueryOptions field | — | Passed through as-is (e.g. select, staleTime, enabled). |
The field written by pageParam must exist in the endpoint’s request schema — the merged request is validated by routar. The adapter (if any) runs per page, consistent with the standard query accessor. Data shape: InfiniteData<PageType, number>.
.infinite.queryKey(params?) — returns the infinite-specific branded key:
todoQuery.getList.infinite.queryKey()
// → ["todos", "getList", "infinite"]
todoQuery.getList.infinite.queryKey({ query: { _limit: 10 } })
// → ["todos", "getList", "infinite", { query: { _limit: 10 } }]Because the infinite key is a prefix-child of the standard key ["todos", "getList"], invalidating the standard key — or the domain $key — also covers the infinite variant.
Per-endpoint defaults from createQueries(api, { defaults }) also merge into the .infinite accessor before per-call options.
Mutation accessors (non-GET endpoints)
Each mutation accessor returns a TanStack mutationOptions object:
// signature: (mutationOptions?) => mutationOptions
todoQuery.create()
todoQuery.create({ invalidates: [todoQuery.getList.queryKey()] })
todoQuery.update({ onSuccess: () => { /* ... */ } }).mutationKey — the branded mutation key for this accessor:
todoQuery.create.mutationKey // ["todos", "create"]Domain key ($key)
Every level of the accessor object exposes $key — the root segments for that domain or sub-domain:
todoQuery.$key // ["todos"]
userQuery.posts.$key // ["api", "v1", "users", "posts"]
// Invalidate the whole domain:
qc.invalidateQueries({ queryKey: todoQuery.$key })invalidates option
Pass an array of query keys to the invalidates option on a mutation accessor. They are stored in mutation.meta and processed by routarMutationCache after the mutation succeeds.
useMutation(
todoQuery.create({
invalidates: [todoQuery.getList.queryKey(), todoQuery.$key],
}),
)Requires routarMutationCache to be wired into your QueryClient (see below). Without it, invalidates does nothing. In development, the library logs a one-time console.warn if a mutation declares invalidates while no routarMutationCache is wired.
Prefer narrow invalidation — target the specific key(s) affected (e.g. todoQuery.getList.queryKey()). Reserve todoQuery.$key for mutations that truly invalidate the whole domain; it refetches all active queries under the domain root, which can be costly.
routarMutationCache(getClient)
A MutationCache factory that processes invalidates stored in mutation.meta. Wire it once when creating your QueryClient.
import { QueryClient } from '@tanstack/react-query'
import { routarMutationCache } from '@routar/react-query'
let queryClient: QueryClient
queryClient = new QueryClient({
mutationCache: routarMutationCache(() => queryClient),
})Parameters
| Parameter | Type | Description |
|---|---|---|
getClient | () => QueryClient | A getter for the QueryClient instance (avoids circular reference at construction time) |
Behaviour
After every successful mutation, routarMutationCache reads mutation.meta.invalidates and calls queryClient.invalidateQueries for each entry. Mutations without invalidates in their meta are unaffected.
You can still combine invalidates with a native onSuccess callback — both run.