Capítulo 6 de 24
Same kitchen-sink dashboard app as ch005, rebuilt so all data fetching and mutations run through TanStack Query instead of ad-hoc loader calls and a custom mutation hook. It is the reference for integrating TanStack Router's loaders with a Query cache: queryClient lives in router context, loaders call ensureQueryData, and components read with useSuspenseQuery.
@tanstack/react-router, @tanstack/router-plugin, @tanstack/react-query, @tanstack/react-query-devtools, @tanstack/react-router-devtools, zod, redaxios, immersrc/main.tsx creates a QueryClient, puts it in the router's context, wraps the app in QueryClientProvider, and sets defaultPreloadStaleTime: 0 so preloading always re-triggers loaders (letting Query's own cache be the source of truth for staleness). src/utils/queryOptions.ts centralizes queryOptions(...) factories and useMutation hooks. src/routes/dashboard.invoices.$invoiceId.tsx mirrors ch005's route but swaps the loader/mutation implementation.// src/main.tsx
export const queryClient = new QueryClient()
const router = createRouter({
routeTree,
context: {
auth: undefined!,
queryClient,
},
defaultPreload: 'intent',
// Since we're using React Query, we don't want loader calls to ever be stale
defaultPreloadStaleTime: 0,
scrollRestoration: true,
})
// src/utils/queryOptions.ts
export const invoiceQueryOptions = (invoiceId: number) =>
queryOptions({
queryKey: ['invoices', invoiceId],
queryFn: () => fetchInvoiceById(invoiceId),
})
export const useUpdateInvoiceMutation = (invoiceId: number) => {
return useMutation({
mutationKey: ['invoices', 'update', invoiceId],
mutationFn: patchInvoice,
onSuccess: () => queryClient.invalidateQueries(),
gcTime: 1000 * 10,
})
}
// src/routes/dashboard.invoices.$invoiceId.tsx
export const Route = createFileRoute('/dashboard/invoices/$invoiceId')({
// ...params/validateSearch same as ch005...
loader: (opts) =>
opts.context.queryClient.ensureQueryData(
invoiceQueryOptions(opts.params.invoiceId),
),
component: InvoiceComponent,
})
function InvoiceComponent() {
const params = Route.useParams()
const invoiceQuery = useSuspenseQuery(invoiceQueryOptions(params.invoiceId))
const invoice = invoiceQuery.data
const updateInvoiceMutation = useUpdateInvoiceMutation(params.invoiceId)
// ...
}
loader only warms the Query cache via queryClient.ensureQueryData(options); the component then reads live, reactive data with useSuspenseQuery(options) using the same queryOptions object, so cache key and fetcher are defined exactly once and shared between the loader and the component.QueryClient in router context so every route's loader can call context.queryClient.ensureQueryData(...) without importing a singleton directly.queryOptions({ queryKey, queryFn }) and reuse that same object in both the router loader (ensureQueryData) and the component (useSuspenseQuery), this keeps cache keys in sync automatically.defaultPreloadStaleTime: 0 on the router when Query owns staleness, otherwise the router's own preload cache can mask stale-query behavior.queryClient.invalidateQueries() on success and rely on Query's cache to refresh, rather than calling router.invalidate() as in the non-Query kitchen sink.queryOptions pattern across a monorepo split into separate router and post-query packages.