Skip to Content
API ReferencecreateQueries() / routarMutationCache

@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 (unless promoted via queryEndpoints).

import { createQueries } from '@routar/react-query' import { todoApi } from './todo' export const todoQuery = createQueries(todoApi)

Parameters

ParameterTypeDescription
apiApiClientWithRouter (from createApi)The typed client produced by createApi; carries its source router on $router
options.keystring | string[]Override the root key segments (default: derived from router prefix)
options.defaultsRecord<string, Options | ((params, q) => Options)>Per-endpoint default options keyed by endpoint name. Each value is a static options object or a function (params, q) => options (see Dynamic defaults). Merged before per-call options (per-call wins). Nested routers supported (the map mirrors the router shape); for mutation endpoints, the value is mutation options including invalidates.
options.infiniteRecord<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.
options.flattenboolean (default false)When true, accessors accept flat params (the union of the request’s path/query/body fields) instead of the nested envelope. See Flatten.
options.queryEndpointsRecord<string, true | nested>Promote non-GET endpoints to query accessors. See POST-as-query.

The previous external factory form createQueries(api, (q) => options) has been removed. To reference sibling key helpers, use the function form of defaults ((params, q) => options) below.

Dynamic defaults

Each defaults value may be a static object or a function (params, q) => options, evaluated lazily on each accessor call:

  • q — the fully-built queries object that createQueries returns. Every key helper (.queryKey(), .mutationKey, .$key) is available, with no circular-reference issues — use it for invalidates.
  • params — the call params for a query accessor, or undefined for a mutation accessor.

The resolved default is merged before the per-call options, so a per-call option always wins.

export const todoQuery = createQueries(todoApi, { defaults: { getDetail: { staleTime: 5 * 60_000 }, // static getList: (params, _q) => ({ staleTime: params?.query?.done ? 60_000 : 0 }), create: (_, q) => ({ invalidates: [q.getList.queryKey()] }), // mutation: params is undefined }, })

Flatten

With flatten: true, every accessor (query, mutation, and .infinite) accepts flat params — the union of the request’s path/query/body fields — instead of the { path, query, body } envelope:

const todoQuery = createQueries(todoApi, { flatten: true }) todoQuery.getDetail({ id: '1' }) // instead of { path: { id: '1' } } todoQuery.update({ id, title }) // instead of { path: { id }, body: { title } } todoQuery.getList({ done: true }) // instead of { query: { done: true } }
  • Fallback to envelope: an endpoint whose buckets collide on a key (e.g. path.id + body.id) or whose body is not a plain object (z.array, z.string, …) keeps the envelope shape. The types enforce this per endpoint.
  • .queryKey() and .infinite.queryKey() follow flatten too — same flat params as the accessor call. Whichever shape you call from (the accessor itself, or these key helpers directly), the key is always built from the normalized envelope underneath, so it’s identical either way. .mutationKey is unaffected (a static array, not a function — mutations have no per-call param-based key). flatten is a call-site convenience on createQueries only — the routar client and HTTP contract always use the envelope.

POST-as-query (queryEndpoints)

By default classification is by HTTP method — GET → query accessor, everything else → mutation accessor. But a POST that is semantically a read (e.g. a complex search whose filters go in the body) belongs in useSuspenseQuery and benefits from query-key caching. Promote it with queryEndpoints:

export const searchQuery = createQueries(searchApi, { queryEndpoints: { search: true }, // POST /search → query accessor }) // now a query accessor — the body is part of the query key useSuspenseQuery(searchQuery.search({ body: { term: 'routar' } })) searchQuery.search.queryKey({ body: { term: 'routar' } }) // → ["search", "search", { body: { term: "routar" } }]
  • The map mirrors the router shape — nest for sub-routers: { search: true, reports: { query: true } }.
  • Promoted endpoints become full query accessors, including the .infinite variant (declare its contract in infinite as usual).
  • The request body is included in the query key (the envelope-based key builder already keys on all params), so distinct searches cache separately.
  • Non-promoted non-GET endpoints stay mutation accessors.

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 a createQueries config entry, but declaring it in createQueries is 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):

FieldTypeDescription
initialPageParamnumberStarting 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 | undefinedReturns 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 | undefinedOptional TanStack native option.
maxPagesnumberOptional TanStack native option.
Any other infiniteQueryOptions fieldPassed 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"]

Per-call headers / timeout

Query, mutation, and infinite-query accessors all accept headers/timeout in their options object, forwarded to the endpoint call (same shape as core’s EndpointCallOptions):

useSuspenseQuery(todoQuery.getDetail({ id: '1' }, { headers: { 'X-Tenant-Id': tenantId } })) useMutation(todoQuery.create({ headers: { 'Idempotency-Key': key }, timeout: 30_000 })) useSuspenseInfiniteQuery(todoQuery.getList.infinite(params, { headers: { ... } }))

Also settable per-endpoint via createQueries({ defaults }) / createQueries({ infinite }) — call-site headers/timeout win, and headers merge shallowly (call-site key wins on collision, other default keys are kept).

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, overrides?)

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

ParameterTypeDescription
getClient() => QueryClientA getter for the QueryClient instance (avoids circular reference at construction time)
overridesOmit<MutationCacheConfig, 'onSuccess'> (optional)Any other MutationCache callback (onError, onSettled, onMutate, …), forwarded as-is. onSuccess stays library-owned and is omitted from the type — implement a global error toast, for example, without reimplementing the invalidates logic yourself.

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.

routarMutationCache(() => queryClient, { onError: (error) => notifyMutationNetworkError(error), })

routarQueryClient(config?)

A convenience factory that returns a QueryClient with routarMutationCache already self-wired — removing the manual self-reference boilerplate:

import { routarQueryClient } from '@routar/react-query' // Before — manual self-reference: let queryClient: QueryClient queryClient = new QueryClient({ mutationCache: routarMutationCache(() => queryClient), }) // After: const queryClient = routarQueryClient()

It accepts any QueryClientConfig (e.g. defaultOptions), forwarded unchanged:

const queryClient = routarQueryClient({ defaultOptions: { queries: { staleTime: 60_000 } }, })

If you pass your own mutationCache, it is respected and routar’s is not wired (so invalidates won’t run unless your cache handles meta.invalidates).

Last updated on