query
Most screens show data that lives on a server: Acme's list of orders, one invoice, the projects a team owns. @kitr/query loads that data, keeps a cached copy so going back to a page is instant, and refreshes it when it changes, whether because you saved something, another tab did, or a live update arrived.
It is TanStack Query, re-exported unchanged, plus a small data contract: you describe each piece of data once with queryFrom, mutationFrom and subscriptionFrom, and every source (a REST API, your own API routes, server actions, realtime channels) plugs into it the same way. Every failure from every source arrives as one error type, KitrError.
It runs in the browser, and on the server while a page renders. For values only the browser owns, such as a cart or a sidebar toggle, use State instead.
This guide follows Acme's order screen from the first query to live updates and tests.
When to use it
Use queries for server data: anything a server owns and the browser only displays or changes. Orders, invoices, the signed-in user's profile and a team's projects are all server data. The cache knows when they are stale and refetches them for you.
Use State for client state: values that exist only in the browser, such as a half-filled cart, a selected tab or a theme choice. Copying server data into a store means you now own keeping it fresh, which is the job this package already does.
Install
kitr add queryFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
What kitr add does:
- Installs
@kitr/queryand@tanstack/react-query, and@tanstack/react-query-devtoolsas a dev dependency. - Adds
'@kitr/query'topackagesand() => import('@kitr/query/provider')toprovidersinkitrrc.ts. - Writes
config/query.ts(shown under Configure). - Adds a
## kitr: querysection toAGENTS.mdwith four rules: import from@kitr/query, keep each feature's queries inapp/queries/<name>.ts, load data in route loaders withensureQueryData, and declare cacheeffectson mutations instead of callinginvalidateQueriesin components.
kitr add query asks no questions. Every UI kit includes it: blank adds it directly, and every kit with HTTP gets it because http requires query. To see the changes without writing them, run it with --dry-run:
kitr add query --dry-runFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
kitr remove query reverses every step above and deletes config/query.ts. It then lists the files that still import @kitr/query, which you remove by hand.
Configure
kitr add query writes this file:
// config/query.ts
import { defineConfig } from '@kitr/query'
export default defineConfig({
// Navigating back within 30s never refetches.
staleTime: 30_000,
})
| Option | Default | Description |
|---|---|---|
staleTime |
30_000 |
How long data counts as fresh, in milliseconds. Fresh data is served from the cache without a request. |
defaultOptions |
{} |
Passed to new QueryClient({ defaultOptions }). Keys under defaultOptions.queries win over staleTime and the retry rule. |
defaultOptions.queries.retry |
no retry on 4xx, two retries otherwise | When a failed query retries. kitr's rule reads error.status: a status from 400 to 499 never retries; anything else retries up to twice. |
createClient |
A function that returns your own QueryClient. When set, every other option is ignored. |
@kitr/query adds no environment variables.
The provider reads this file at boot, builds the client with createQueryClient(config), binds it in the container as queryClient, wraps the React tree in QueryClientProvider and hands the same client to the router as context.queryClient.
To change the defaults for every query, for example to stop refetching when the window regains focus, pass TanStack's own options:
// config/query.ts
import { defineConfig } from '@kitr/query'
export default defineConfig({
staleTime: 60_000,
defaultOptions: {
queries: { refetchOnWindowFocus: false },
mutations: { retry: 0 },
},
})
To build the client yourself, for example to share one with code outside kitr, return it from createClient:
// config/query.ts
import { defineConfig, QueryClient } from '@kitr/query'
export default defineConfig({
createClient: () =>
new QueryClient({ defaultOptions: { queries: { staleTime: 10_000, retry: 1 } } }),
})
Show your first query
A query needs two things: a key, its name in the cache, and a fetch function that loads it. Put both in the feature's queries.ts. This one returns fixed data so it runs in a fresh kitr new project; the next sections swap in real sources.
// app/queries/orders.ts
import { createKeys } from 'kitr/app'
import { queryFrom } from '@kitr/query'
export interface Order {
id: string
customer: string
total: number
status: 'open' | 'paid' | 'shipped'
}
export const orderKeys = createKeys('orders')
export const ordersQuery = queryFrom({
key: () => orderKeys.list(),
fetch: async (): Promise<Order[]> => [
{ id: 'ord_1', customer: 'Ada', total: 120, status: 'paid' },
{ id: 'ord_2', customer: 'Grace', total: 80, status: 'open' },
],
})
Then read it in a page:
// app/routes.ts
r.page('/orders', { lazy: () => import('./pages/orders') })
// app/pages/orders.tsx
import { useQuery } from '@kitr/query'
import { ordersQuery } from '#app/queries/orders'
export default function OrdersPage() {
const { data: orders, isPending } = useQuery(ordersQuery.options())
if (isPending) return <p>Loading orders…</p>
return (
<ul>
{orders?.map((order) => (
<li key={order.id}>
{order.customer}: ${order.total} ({order.status})
</li>
))}
</ul>
)
}
Open /orders and you see two lines, Ada: $120 (paid) and Grace: $80 (open). Leave the page and come back within 30 seconds: the list shows at once, with no loading state, because it comes from the cache.
ordersQuery.options() returns ordinary TanStack queryOptions. Anything that takes query options takes it: useQuery, useSuspenseQuery, useQueries, queryClient.fetchQuery, prefetchQuery and ensureQueryData.
Name cache entries with keys
A key is an array that names a piece of data in the cache. Two calls with equal keys share one cache entry and one request. createKeys from kitr/app builds keys in one shape for each resource, so every part of the app agrees on what to refresh:
// app/lib/orders/keys.ts
import { createKeys } from 'kitr/app'
export const orderKeys = createKeys('orders')
orderKeys.all // => ['orders']
orderKeys.lists() // => ['orders', 'list']
orderKeys.list() // => ['orders', 'list']
orderKeys.list({ status: 'open' }) // => ['orders', 'list', { status: 'open' }]
orderKeys.details() // => ['orders', 'detail']
orderKeys.detail('ord_1') // => ['orders', 'detail', 'ord_1']
orderKeys.custom('by-customer', 'ada') // => ['orders', 'by-customer', 'ada']
Invalidating a key also invalidates every key that starts with it. orderKeys.lists() covers every filtered list; orderKeys.all covers everything about orders. keyMatches(prefix, key) from kitr/app applies the same rule, if you need it in your own code:
import { keyMatches } from 'kitr/app'
keyMatches(['orders', 'list'], ['orders', 'list', { status: 'open' }]) // => true
keyMatches(['orders', 'detail'], ['orders', 'list']) // => false
A key must include every input that changes the result. A list filtered by status and page needs both in its key, or two different pages end up in one cache entry.
Pass input to a query
The first argument of key and fetch is the query's input. Type it once on key, and options(input), key(input) and fetch(input) all expect it:
// app/queries/orders.ts
import { createKeys } from 'kitr/app'
import { queryFrom } from '@kitr/query'
import type { Order } from './types'
export const orderKeys = createKeys('orders')
export const orderQuery = queryFrom({
key: (id: string) => orderKeys.detail(id),
fetch: async (id, { signal }) => {
const res = await fetch(`/api/orders/${id}`, { signal })
return (await res.json()) as Order
},
})
export const ordersQuery = queryFrom({
key: (filters: { status?: Order['status']; page: number }) => orderKeys.list(filters),
fetch: async (filters, { signal }) => {
const params = new URLSearchParams({ page: String(filters.page) })
if (filters.status) params.set('status', filters.status)
const res = await fetch(`/api/orders?${params}`, { signal })
return (await res.json()) as Order[]
},
})
fetch receives a signal. Pass it on: when a component unmounts or the key changes mid-flight, TanStack aborts the request.
You rarely write fetch by hand. Each data source package produces the contract for you, and you pass it straight to queryFrom:
| Source | Package | Contract |
|---|---|---|
| An existing REST API | HTTP | queryFrom(httpQuery(http, { path: 'orders', key: () => orderKeys.list() })) |
| Your own API routes | API | queryFrom(api.orders.index.query()) |
| A server action | Actions | Nothing to wrap: defineQuery('orders.list', …) registers a named query, used as useQuery('orders.list') |
| A realtime channel | Realtime | useChannel(realtime(), orderChannel, params) |
Components never learn which source they read from. Swap a REST endpoint for a server action and no component changes.
Read data in a component
useQuery returns data as undefined until the first load finishes, plus flags for every state. It is the hook to use when the component draws its own loading and error states:
// app/components/orders/order-summary.tsx
import { useQuery } from '@kitr/query'
import { orderQuery } from '#app/queries/orders'
export function OrderSummary({ id }: { id: string }) {
const { data: order, isPending, isError, error, isFetching } = useQuery(orderQuery.options(id))
if (isPending) return <p>Loading…</p>
if (isError) return <p role="alert">{error.message}</p>
return (
<p>
{order.customer} owes ${order.total} {isFetching ? '(refreshing)' : null}
</p>
)
}
useSuspenseQuery returns data that is always defined. The component suspends until the data arrives, and the nearest <Suspense> boundary shows the fallback. A route's pendingComponent acts as that boundary:
// app/components/orders/order-list.tsx
import { useSuspenseQuery } from '@kitr/query'
import { ordersQuery } from '#app/queries/orders'
export function OrderList({ page }: { page: number }) {
const { data: orders } = useSuspenseQuery(ordersQuery.options({ page }))
return (
<ul>
{orders.map((order) => (
<li key={order.id}>{order.customer}</li>
))}
</ul>
)
}
Errors thrown by a suspense query go to the nearest error boundary, such as the route's errorComponent.
options() returns a plain object, so you can spread it and add any TanStack option for one call site. Here only the count of open orders is selected, and the component re-renders only when that number changes:
// app/components/orders/open-count.tsx
import { useQuery } from '@kitr/query'
import { ordersQuery } from '#app/queries/orders'
export function OpenOrderCount() {
const { data: count } = useQuery({
...ordersQuery.options({ status: 'open', page: 1 }),
select: (orders) => orders.length,
refetchInterval: 60_000,
})
return <span>{count ?? 0} open</span>
}
Load data before the page renders
Start the request in the route's loader so the data arrives with the page instead of after it. The router gives every loader context.queryClient, the same client the components read from:
// app/routes.ts
import { orderQuery } from '#app/queries/orders'
r.page('/orders/$orderId', {
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(orderQuery.options(params.orderId)),
lazy: () => import('./pages/orders/orderId'),
})
// app/pages/orders/orderId.tsx
import { useSuspenseQuery } from '@kitr/query'
import { orderQuery } from '#app/queries/orders'
import { getRouteApi } from '@kitr/router'
const route = getRouteApi('/orders/$orderId')
export default function OrderPage() {
const { orderId } = route.useParams()
const { data: order } = useSuspenseQuery(orderQuery.options(orderId))
return <h1>Order {order.id} for {order.customer}</h1>
}
ensureQueryData returns cached data when there is some and fetches otherwise. With server rendering, the server runs the loader, renders with the data and sends it along with the HTML, so the browser does not fetch it again.
Don't await data the page can live without. Start it with prefetchQuery and let the component suspend on it, so the page renders without waiting:
// app/routes.ts
import { ordersQuery } from '#app/queries/orders'
import { OrderList } from '#app/components/orders/order-list'
import { OpenOrderCount } from '#app/components/orders/open-count'
r.page('/orders/', {
loader: async ({ context }) => {
// Needed for the first paint: wait for it.
await context.queryClient.ensureQueryData(ordersQuery.options({ page: 1 }))
// Nice to have: start it, don't wait.
void context.queryClient.prefetchQuery(ordersQuery.options({ status: 'open', page: 1 }))
},
<>
<OpenOrderCount />
<OrderList page={1} />
</>
),
lazy: () => import('./pages/orders/index'),
})
// app/pages/orders/index.tsx
export default () => (
The kitr router preloads a route's loader when the pointer rests on a link to it, so most pages open with their data already in the cache.
Change data with a mutation
A mutation changes something on the server: pay an invoice, rename a project, ship an order. Define it next to the queries, then run it in a component with useMutation:
// app/queries/orders.ts
import { mutationFrom } from '@kitr/query'
import type { Order } from './types'
export const shipOrder = mutationFrom({
mutate: async (input: { id: string; carrier: string }) => {
const res = await fetch(`/api/orders/${input.id}/ship`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ carrier: input.carrier }),
})
return (await res.json()) as Order
},
})
// app/components/orders/ship-button.tsx
import { useMutation } from '@kitr/query'
import { shipOrder } from '#app/queries/orders'
export function ShipButton({ id }: { id: string }) {
const ship = useMutation(shipOrder)
return (
<>
<button
type="button"
disabled={ship.isPending}
onClick={() => ship.mutate({ id, carrier: 'UPS' })}
>
{ship.isPending ? 'Shipping…' : 'Ship order'}
</button>
{ship.error ? <p role="alert">{ship.error.message}</p> : null}
</>
)
}
useMutation returns TanStack's useMutation result: mutate, mutateAsync, isPending, error, data and reset. Pass any useMutation option except mutationFn as the second argument. Your onSuccess runs after the mutation's own cache effects, so the cache is already updated when it runs:
// app/hooks/orders/use-ship-and-return.ts
import { useNavigate } from '@tanstack/react-router'
import { useMutation } from '@kitr/query'
import { shipOrder } from '#app/queries/orders'
export function useShipAndReturn() {
const navigate = useNavigate()
return useMutation(shipOrder, {
onSuccess: (order) => navigate({ to: '/orders/$orderId', params: { orderId: order.id } }),
onError: (error) => console.warn('Ship failed', error.message),
})
}
Refresh what a mutation changed
After a save, every screen showing the changed data must update. Instead of telling each component to refetch, the mutation declares its effects on the cache, and useMutation applies them when the mutation succeeds:
// app/queries/orders.ts
import { mutationFrom } from '@kitr/query'
import { orderKeys } from './keys'
import type { Order } from './types'
export const shipOrder = mutationFrom({
mutate: async (input: { id: string; carrier: string }) => {
const res = await fetch(`/api/orders/${input.id}/ship`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ carrier: input.carrier }),
})
return (await res.json()) as Order
},
effects: (order, input) => ({
set: [{ key: orderKeys.detail(input.id), data: order }],
invalidate: [orderKeys.lists()],
}),
})
| Effect | What it does | Use it when |
|---|---|---|
set |
Writes data into the entry at key with no request. |
The server returned the new value. |
invalidate |
Marks every entry starting with the key as stale. Entries on screen refetch now; the rest refetch next time they are used. | You don't have the new value, or many entries changed (every filtered list). |
remove |
Drops the entries from the cache. The next read fetches from scratch. | The thing was deleted. |
They run in that order: set, then remove, then invalidate. The mutation's onSuccess waits for the invalidated queries to refetch, so ship.isPending stays true until the lists on screen have refetched.
effects is either a function of the result and the input, as above, or a plain object when the effects never depend on the result:
// app/queries/invoices.ts
import { createKeys } from 'kitr/app'
import { mutationFrom } from '@kitr/query'
export const invoiceKeys = createKeys('invoices')
export const deleteInvoice = mutationFrom({
mutate: async (id: string) => {
await fetch(`/api/invoices/${id}`, { method: 'DELETE' })
},
effects: { invalidate: [invoiceKeys.lists()], remove: [invoiceKeys.details()] },
})
Don't call queryClient.invalidateQueries in the component after mutate. The next component that runs the same mutation forgets to, and its screen shows stale data. Declare the effect on the mutation once.
effectsFor(result, input) returns the effects a result would produce, which helps when you test them:
import { shipOrder } from '#app/queries/orders'
shipOrder.effectsFor(
{ id: 'ord_2', customer: 'Grace', total: 80, status: 'shipped' },
{ id: 'ord_2', carrier: 'UPS' },
)
// => {
// set: [{ key: ['orders', 'detail', 'ord_2'], data: { id: 'ord_2', customer: 'Grace', total: 80, status: 'shipped' } }],
// invalidate: [['orders', 'list']],
// }
Combine effects from several resources
Shipping an order also changes the customer's open invoice. mergeEffects from kitr/app joins several effects into one, concatenating each list:
// app/queries/orders.ts
import { mergeEffects } from 'kitr/app'
import { mutationFrom } from '@kitr/query'
import { invoiceKeys } from '#app/queries/invoices'
import { orderKeys } from './keys'
import type { Order } from './types'
export const shipOrder = mutationFrom({
mutate: async (input: { id: string; carrier: string }) => {
const res = await fetch(`/api/orders/${input.id}/ship`, { method: 'POST' })
return (await res.json()) as Order
},
effects: (order) =>
mergeEffects(
{ set: [{ key: orderKeys.detail(order.id), data: order }] },
{ invalidate: [orderKeys.lists()] },
{ invalidate: [invoiceKeys.lists()] },
),
})
// effects for ord_2 => { set: [...], invalidate: [['orders', 'list'], ['invoices', 'list']] }
mergeEffects skips undefined, so a helper that returns no effect for some inputs can sit in the list.
Update the cache outside a mutation
Sometimes the change comes from somewhere that is not a mutation: a message from a service worker, a postMessage from another window, a test. applyCacheEffect applies the same effect shape to any client, and invalidate marks several keys stale in one call:
// app/lib/orders/sync.ts
import { app } from '#app/app'
import { applyCacheEffect, invalidate } from '@kitr/query'
import { invoiceKeys } from '#app/queries/invoices'
import { orderKeys } from './keys'
const queryClient = app.container.use('queryClient')
export async function onOrderPaidElsewhere(orderId: string) {
await applyCacheEffect(queryClient, {
remove: [orderKeys.detail(orderId)],
invalidate: [orderKeys.lists()],
})
}
export function refreshBilling() {
return invalidate(queryClient, orderKeys.all, invoiceKeys.all)
}
In a component, read the client with TanStack's useQueryClient() instead of the container. For anything not covered by effects, the client is TanStack's own QueryClient: setQueryData, getQueryData, cancelQueries and the rest all work.
Update the screen before the server answers
For an optimistic update, write the expected value before the request, and roll back if it fails. Pass TanStack's onMutate and onError through useMutation; the mutation's own effects still run on success:
// app/hooks/orders/use-mark-paid.ts
import { mutationFrom, useMutation, useQueryClient } from '@kitr/query'
import { orderKeys } from '#app/lib/orders/keys'
import type { Order } from '#app/lib/orders/types'
export const markPaid = mutationFrom({
mutate: async (id: string) => {
const res = await fetch(`/api/orders/${id}/pay`, { method: 'POST' })
return (await res.json()) as Order
},
effects: (order) => ({
set: [{ key: orderKeys.detail(order.id), data: order }],
invalidate: [orderKeys.lists()],
}),
})
export function useMarkPaid(id: string) {
const queryClient = useQueryClient()
const key = orderKeys.detail(id)
return useMutation(markPaid, {
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: key })
const previous = queryClient.getQueryData<Order>(key)
if (previous) queryClient.setQueryData<Order>(key, { ...previous, status: 'paid' })
return { previous }
},
onError: (_error, _input, snapshot) => {
// `snapshot` is what onMutate returned.
const previous = (snapshot as { previous?: Order } | undefined)?.previous
if (previous) queryClient.setQueryData(key, previous)
},
})
}
Use a query or mutation by name
Server actions register every defineQuery('orders.list', ...) and defineMutation('orders.ship', ...) under its name, and kitr types writes those names into .kitr/actions.ts so they are typed. Every hook in @kitr/query then takes the name as its first argument, the input as the second, and TanStack overrides as the third:
// app/components/open-orders.tsx
import { queryOptions, useMutation, useQuery, useSuspenseQuery } from '@kitr/query'
export function OpenOrders() {
const { data } = useQuery('orders.list', { status: 'open' }, { refetchInterval: 10_000 })
const { data: orders } = useSuspenseQuery('orders.list', { status: 'open' })
const ship = useMutation('orders.ship', { onSuccess: () => toast('Shipped') })
return <ul>{orders.map((o) => <li key={o.id}>{o.customer}</li>)}</ul>
}
// In a loader:
const options = queryOptions('orders.list', { status: 'open' })
The input is optional when every field of it is optional. A name that is not registered, or a mutation name given to useQuery, is a type error; at run time the hook throws [@kitr/query] "orders.lsit" is not registered with the names that are.
useMutation(name) works the same way as useMutation(definition): both apply the mutation's cache effects, then your own onSuccess. Outside React, call runs an operation by name. A query fetches, a mutation mutates; in the browser it is a request, on the server the handler runs in-process:
import { call } from '@kitr/query'
const open = await call('orders.list', { status: 'open' })
// => [{ id: 'ord_2', customer: 'Grace', total: 80, status: 'open' }]
await call('orders.ship', { id: 'ord_2', carrier: 'UPS' })
Every hook also accepts the imported definition in place of the name, useQuery(listOrders, input), and plain TanStack options as before. The three forms produce the same query options, so a component and a loader using different forms share one cache entry.
Call a source without the cache
fetch(input, signal?) on a query and mutate(input) on a mutation call the source directly. They skip the cache, skip effects and do not normalise errors to KitrError. Use them in scripts, server code or a test of the source itself:
// scripts/print-open-orders.ts
import { ordersQuery, shipOrder } from '#app/queries/orders'
const open = await ordersQuery.fetch({ status: 'open', page: 1 })
// => [{ id: 'ord_2', customer: 'Grace', total: 80, status: 'open' }]
await shipOrder.mutate({ id: 'ord_2', carrier: 'UPS' })
// The cache is untouched: no effects ran.
key(input) returns the cache key, and contract holds the full contract with its kind and source ('http', 'api', 'action', 'socket', or undefined for a hand-written one).
Subscribe to live updates
A subscription is a stream of events whose effects land in the same cache that useQuery reads. When Linus ships an order in his browser, the order page Ada has open updates without a refetch.
Define it with the input and event types, a key, a subscribe function that returns an unsubscribe function, and effects that map each event to cache changes:
// app/lib/orders/subscriptions.ts
import { subscriptionFrom } from '@kitr/query'
import { orderKeys } from './keys'
import type { Order } from './types'
type OrderEvent = { type: 'updated'; order: Order } | { type: 'deleted'; id: string }
export const orderEvents = subscriptionFrom<string, OrderEvent>({
key: (teamId) => ['order-events', teamId],
subscribe: (teamId, { next, error }) => {
const source = new EventSource(`/api/teams/${teamId}/order-events`)
source.onmessage = (message) => next(JSON.parse(message.data))
source.onerror = () => error?.({ code: 'NETWORK', message: 'Order feed lost', status: 0 })
return () => source.close()
},
effects: (event) =>
event.type === 'updated'
? { set: [{ key: orderKeys.detail(event.order.id), data: event.order }], invalidate: [orderKeys.lists()] }
: { remove: [orderKeys.detail(event.id)], invalidate: [orderKeys.lists()] },
})
Then subscribe from the component that needs it. The hook subscribes on mount, unsubscribes on unmount and resubscribes only when the input's key changes:
// app/components/orders/live-orders.tsx
import { useSubscription } from '@kitr/query'
import { OrderList } from './order-list'
import { orderEvents } from '#app/lib/orders/subscriptions'
export function LiveOrders({ teamId }: { teamId: string }) {
const live = useSubscription(orderEvents, teamId, {
onEvent: (event) => console.info('order event', event.type),
onError: (error) => console.warn(error.code, error.message),
})
return (
<>
<p>{live.status === 'open' ? 'Live' : live.status === 'error' ? 'Offline' : 'Paused'}</p>
<OrderList page={1} />
</>
)
}
| Option | Default | Description |
|---|---|---|
enabled |
true |
Set false to not subscribe, for example until the user opens the panel. |
onEvent |
Called with every event, after it is stored as lastEvent. |
|
onError |
Called with a KitrErrorShape when the source reports an error. |
The hook returns { status, lastEvent, error }. status is 'idle' before subscribing and after unmount, 'open' while subscribed, and 'error' after the source calls error. You rarely write subscribe by hand: Realtime channels produce the same shape from a channel definition, with WebSocket, Socket.IO or SSE underneath.
Handle errors
Every failure from options() and useMutation, whatever its source, reaches you as a KitrError from kitr/app with a code, status, message and, for validation failures, fieldErrors:
// app/components/orders/order-error.tsx
import { isKitrError } from 'kitr/app'
export function OrderError({ error }: { error: Error }) {
if (isKitrError(error) && error.code === 'NOT_FOUND') return <p>This order no longer exists.</p>
if (isKitrError(error) && error.code === 'FORBIDDEN') return <p>Ask Grace for access to this team.</p>
return <p role="alert">{error.message}</p>
}
A thrown wire shape such as { code: 'CONFLICT', message: 'Order already shipped', status: 409 } becomes a KitrError with that code. A TypeError whose message mentions fetch (Chrome's Failed to fetch) becomes NETWORK with status 0, and an AbortError becomes TIMEOUT. Anything else becomes INTERNAL with the original message.
| Code | Status | Retried by queries |
|---|---|---|
BAD_REQUEST |
400 | no |
UNAUTHORIZED |
401 | no |
FORBIDDEN |
403 | no |
NOT_FOUND |
404 | no |
TIMEOUT |
408 | no |
CONFLICT |
409 | no |
VALIDATION |
422 | no |
RATE_LIMITED |
429 | no |
INTERNAL |
500 | twice |
UNAVAILABLE |
503 | twice |
NETWORK |
0 | twice |
Queries retry only failures that might pass on a second try. A 4xx means the request itself is wrong, so it fails at once and the user sees the error without a wait. Mutations are not retried. Forms puts a VALIDATION error's fieldErrors under the right inputs, so a mutateAsync inside a form's onSubmit needs no error code of its own.
Tune caching per query
staleTime on the contract overrides the config default for one query. Use a long one for data that rarely changes and 0 for data that must always be fresh:
// app/queries/teams.ts
import { createKeys } from 'kitr/app'
import { queryFrom } from '@kitr/query'
export const teamKeys = createKeys('teams')
export const teamsQuery = queryFrom({
key: () => teamKeys.list(),
fetch: async (_input, { signal }) => {
const res = await fetch('/api/teams', { signal })
return (await res.json()) as { id: string; name: string }[]
},
// Teams change rarely: serve them from the cache for ten minutes.
staleTime: 10 * 60_000,
})
teamsQuery.options() now carries staleTime: 600000. A query without its own staleTime uses the config value. For one call site, spread the options and override them:
// app/components/teams/team-picker.tsx
import { useQuery } from '@kitr/query'
import { teamsQuery } from '#app/queries/teams'
export function TeamPicker() {
// This screen edits teams, so it always wants the latest list.
const { data: teams = [] } = useQuery({ ...teamsQuery.options(), staleTime: 0 })
return (
<select>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
)
}
Render on the server
On the server, every request gets its own QueryClient. The provider binds queryClient as a scoped binding, so each forked app that renders a request builds a fresh cache, and one customer's orders can never render into another customer's page. In the browser there is one app, so there is one client for the whole session.
Nothing in your code changes for this. Read the client through context.queryClient in loaders and useQueryClient() in components, and you always get the one for the current request. Don't import app and call app.container.use('queryClient') from code that runs during a server render: it reads the root app's client, not the request's.
Testing
In a test, boot an app with the query provider, seed the cache with the data the component needs, and render inside the app's providers. createTestApp from @kitr/testing boots the app; renderWithKitr from @kitr/testing/react renders inside KitrProvider and takes queryData as [key, data] pairs. Seeded data is fresh for 30 seconds, so the component reads it without calling fetch:
// app/components/orders/orders.test.tsx
import { act, renderHook, screen } from '@testing-library/react'
import { createKeys } from 'kitr/app'
import {
mutationFrom,
queryFrom,
queryProvider,
useMutation,
useSuspenseQuery,
} from '@kitr/query'
import { KitrProvider } from '@kitr/react'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { type ReactNode, Suspense } from 'react'
import { describe, expect, it, vi } from 'vitest'
interface Order {
id: string
customer: string
status: 'open' | 'shipped'
}
const orderKeys = createKeys('orders')
const fetchOrders = vi.fn(async (): Promise<Order[]> => [])
const ordersQuery = queryFrom({ key: () => orderKeys.list(), fetch: fetchOrders })
const shipOrder = mutationFrom({
mutate: async (id: string): Promise<Order> => ({ id, customer: 'Grace', status: 'shipped' }),
effects: (order) => ({
set: [{ key: orderKeys.detail(order.id), data: order }],
invalidate: [orderKeys.lists()],
}),
})
function OrderList() {
const { data: orders } = useSuspenseQuery(ordersQuery.options())
return (
<ul>
{orders.map((order) => (
<li key={order.id}>{order.customer}</li>
))}
</ul>
)
}
describe('orders', () => {
it('renders seeded orders without fetching', async () => {
const app = await createTestApp({ providers: [queryProvider] })
renderWithKitr(
<Suspense fallback="Loading">
<OrderList />
</Suspense>,
{ app, queryData: [[orderKeys.list(), [{ id: 'ord_1', customer: 'Ada', status: 'open' }]]] },
)
expect(await screen.findByText('Ada')).toBeTruthy()
expect(fetchOrders).not.toHaveBeenCalled()
})
it('shipping writes the order and marks every list stale', async () => {
const app = await createTestApp({ providers: [queryProvider] })
const queryClient = app.container.use('queryClient')
queryClient.setQueryData(orderKeys.list(), [])
const wrapper = ({ children }: { children: ReactNode }) => (
<KitrProvider app={app}>{children}</KitrProvider>
)
const { result } = renderHook(() => useMutation(shipOrder), { wrapper })
await act(() => result.current.mutateAsync('ord_2'))
expect(queryClient.getQueryData(orderKeys.detail('ord_2'))).toEqual({
id: 'ord_2',
customer: 'Grace',
status: 'shipped',
})
expect(queryClient.getQueryState(orderKeys.list())?.isInvalidated).toBe(true)
})
})
The assertions you will write most:
| To check | Assert |
|---|---|
| A mutation wrote an entry | queryClient.getQueryData(key) equals the new value |
| A mutation marked entries stale | queryClient.getQueryState(key)?.isInvalidated is true |
| A mutation removed an entry | queryClient.getQueryData(key) is undefined |
| A component did not refetch | the fetch mock was not called |
| A failure reached the UI | result.current.error is a KitrError with the expected code |
To test effects without React, call effectsFor and apply the result to a bare client. No provider is needed:
// app/lib/orders/effects.test.ts
import { applyCacheEffect, QueryClient } from '@kitr/query'
import { expect, it } from 'vitest'
import { orderKeys } from './keys'
import { shipOrder } from '#app/queries/orders'
it('shipping replaces the order and refreshes lists', async () => {
const queryClient = new QueryClient()
queryClient.setQueryData(orderKeys.list({ status: 'open' }), [{ id: 'ord_2' }])
const shipped = { id: 'ord_2', customer: 'Grace', total: 80, status: 'shipped' as const }
await applyCacheEffect(queryClient, shipOrder.effectsFor(shipped, { id: 'ord_2', carrier: 'UPS' }))
expect(queryClient.getQueryData(orderKeys.detail('ord_2'))).toEqual(shipped)
expect(queryClient.getQueryState(orderKeys.list({ status: 'open' }))?.isInvalidated).toBe(true)
})
To fake the network itself rather than the query, use Mocks: its handlers answer fetch in tests and in the browser, so the query runs end to end.
Extend
Every data source is a function that returns a contract. To add a source kitr does not ship, such as a GraphQL endpoint or a vendor SDK, write one function per kind and pass its result to queryFrom, mutationFrom or subscriptionFrom. The types are QueryContract, MutationContract and SubscriptionContract from kitr/app (see the API reference):
| Member | Kind | Description |
|---|---|---|
key(input) |
query, subscription | The cache key. Include every input that changes the result. |
fetch(input, { signal }) |
query | Load the data. Pass signal to the request so TanStack can cancel it. |
mutate(input) |
mutation | Make the change and return the result. |
subscribe(input, { next, error }) |
subscription | Start the stream, call next per event, return a function that stops it. |
effects |
mutation, subscription | Cache effects after a success or per event. |
staleTime |
query | Freshness for this query, overriding the config. |
source |
all | A label for devtools, stored in TanStack's meta.source. |
This source turns a GraphQL operation into a query contract:
// app/lib/graphql.ts
import { KitrError, type QueryContract, type QueryKey } from 'kitr/app'
interface GraphqlQueryOptions<TInput> {
document: string
key: (input: TInput) => QueryKey
variables?: (input: TInput) => Record<string, unknown>
staleTime?: number
}
export function graphqlQuery<TInput = void, TOutput = unknown>(
opts: GraphqlQueryOptions<TInput>,
): Omit<QueryContract<TInput, TOutput>, 'kind'> {
return {
source: 'graphql',
key: opts.key,
staleTime: opts.staleTime,
fetch: async (input, { signal }) => {
const res = await fetch('https://api.acme.com/graphql', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: opts.document, variables: opts.variables?.(input) }),
signal,
})
if (!res.ok) throw KitrError.fromStatus(res.status)
const body = (await res.json()) as { data?: TOutput; errors?: { message: string }[] }
if (body.errors?.length) throw new KitrError('BAD_REQUEST', body.errors[0]!.message)
return body.data as TOutput
},
}
}
// app/queries/projects.ts
import { createKeys } from 'kitr/app'
import { queryFrom } from '@kitr/query'
import { graphqlQuery } from '#app/lib/graphql'
export const projectKeys = createKeys('projects')
export const projectsQuery = queryFrom(
graphqlQuery<{ teamId: string }, { projects: { id: string; name: string }[] }>({
document: 'query ($teamId: ID!) { projects(teamId: $teamId) { id name } }',
key: ({ teamId }) => projectKeys.list({ teamId }),
variables: ({ teamId }) => ({ teamId }),
}),
)
Throw a KitrError (or anything with code, message and status) so components and forms see the same shape as every other source. Whatever you throw is normalised anyway, so a plain Error still arrives as INTERNAL.
Devtools
With Devtools installed, the provider adds a Query panel in development. Its badge shows how many queries are fetching (2↻) or, when idle, how many are cached. The panel lists every cached query, newest first, with its key, a status badge (fetching, error, stale or fresh), its observer count and how long ago it updated. Select a row to see its data or error, and to invalidate, refetch or remove that one entry. Two panel actions apply to the whole cache: Invalidate all queries and Clear cache.
Failed mutations are logged to the Log panel under query as mutation failed: <mutationKey> with the error attached.
Next steps
- API reference
- TanStack Query docs: everything there works as written, imported from
@kitr/query. - HTTP: turn REST endpoints into queries and mutations.
- Realtime: live channels whose events update the cache.
- Loading and saving data: how every kitr data source shares one contract.