Guide

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 query

First 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/query and @tanstack/react-query, and @tanstack/react-query-devtools as a dev dependency.
  • Adds '@kitr/query' to packages and () => import('@kitr/query/provider') to providers in kitrrc.ts.
  • Writes config/query.ts (shown under Configure).
  • Adds a ## kitr: query section to AGENTS.md with four rules: import from @kitr/query, keep each feature's queries in app/queries/<name>.ts, load data in route loaders with ensureQueryData, and declare cache effects on mutations instead of calling invalidateQueries in 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-run

First 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