Capítulo 6 de 24

Chapter 6: Kitchen Sink with React Query

Core Idea

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.

Setup

  • Routing style: file-based
  • Key dependencies: @tanstack/react-router, @tanstack/router-plugin, @tanstack/react-query, @tanstack/react-query-devtools, @tanstack/react-router-devtools, zod, redaxios, immer
  • Structure: src/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.

Code Example

// 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)
  // ...
}
  • What it demonstrates: the router's 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.

Key Takeaways

  1. Put the QueryClient in router context so every route's loader can call context.queryClient.ensureQueryData(...) without importing a singleton directly.
  2. Define fetch logic once with 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.
  3. Set defaultPreloadStaleTime: 0 on the router when Query owns staleness, otherwise the router's own preload cache can mask stale-query behavior.
  4. Mutations call queryClient.invalidateQueries() on success and rely on Query's cache to refresh, rather than calling router.invalidate() as in the non-Query kitchen sink.

Connects To

  • ch005-kitchen-sink-file-based: the same dashboard app without React Query, useful for seeing exactly which pieces (loader body, mutation hook, cache invalidation) change when Query is introduced.
  • router-monorepo-react-query (not covered in depth here): applies this same loader-plus-queryOptions pattern across a monorepo split into separate router and post-query packages.