ZenoZENO

Queries

Suspense boundaries, server prefetching and timing presets for TanStack Query

@zeno-lib/query is a small layer over TanStack Query for suspense queries in a server-rendered React app. It gives you one boundary that handles loading, errors and retry, a server component that prefetches a route's data into the page, and a set of timing presets so queries share a few staleTime values instead of each inventing its own. The package is UI-free; the error card that goes with it comes from the registry, so its source lives in your app and you can restyle it.

Installation

pnpm add @zeno-lib/query @tanstack/react-query
pnpm dlx shadcn@latest add zeno-lib/zeno/query-error-fallback

The registry item adds QueryErrorFallback to components/query/ and installs shadcn's Button. Your app still needs its own QueryClientProvider.

Suspense boundaries

Wrap every component that calls useSuspenseQuery in a QuerySuspense. It nests TanStack's recommended QueryErrorResetBoundary, an error boundary and Suspense. fallback shows while the query loads, and errorFallback shows when it fails, with an onRetry that resets the query.

"use client"

import { QuerySuspense } from "@zeno-lib/query/query-suspense"
import { QuerySuspenseLoading } from "@zeno-lib/query/query-suspense-loading"
import { useSuspenseQuery } from "@tanstack/react-query"
import { QueryErrorFallback } from "@/components/query/query-error-fallback"
import { Skeleton } from "@/components/ui/skeleton"

function Notes({ dealId }: { dealId: string }) {
  const { data: notes } = useSuspenseQuery(notesQueryOptions(dealId))
  return (
    <ul>
      {notes.map((note) => (
        <li key={note.id}>{note.content}</li>
      ))}
    </ul>
  )
}

export function NotesCard({ dealId }: { dealId: string }) {
  return (
    <QuerySuspense
      fallback={
        <QuerySuspenseLoading className="flex flex-col gap-2 p-4">
          <Skeleton className="h-4 w-full" />
          <Skeleton className="h-4 w-4/5" />
        </QuerySuspenseLoading>
      }
      errorFallback={(props) => (
        <QueryErrorFallback {...props} fallbackMessage="Could not load notes." />
      )}
    >
      <Notes dealId={dealId} />
    </QuerySuspense>
  )
}

QuerySuspenseLoading is a plain div that sets aria-busy and aria-live for you, so screen readers announce the skeleton without an extra wrapper.

Why the boundary waits for hydration

A Server Function cannot run during the initial server render. React throws "Server Functions cannot be called during initial render" there. So a useSuspenseQuery whose queryFn calls one would render its error into the HTML, while the browser suspends instead. The mismatch makes React re-render the whole subtree on the client.

To keep both passes identical, QuerySuspense renders only its fallback on the server and through hydration, then runs the query in the browser. That avoids the error, but the route arrives as a skeleton. Prefetching lets the server render the real HTML.

Prefetching a route

QueryHydrationBoundary is an async server component. It hands a fresh QueryClient to your prefetch, awaits it, and dehydrates the cache into the page:

// app/deals/page.tsx
import { QueryHydrationBoundary } from "@zeno-lib/query/query-hydration-boundary"

export default function DealsPage() {
  return (
    <QueryHydrationBoundary
      prefetch={(queryClient) =>
        Promise.all([
          queryClient.fetchQuery(dealsQueryOptions(defaultFilters)),
          queryClient.prefetchQuery(advisorsQueryOptions()),
        ])
      }
    >
      <DealsBoard />
    </QueryHydrationBoundary>
  )
}

Inside it, every QuerySuspense renders on the server too, and the queries resolve from the cache in both passes. Three rules keep that working:

  • Prefetch through the same queryOptions factory the browser uses, unchanged. The cache entry needs the exact key and value the client would build, or the client refetches and the prefetch is wasted. Don't spread the options and swap the queryFn.
  • Prefetch every suspense query the server pass reaches. The boundary lets every QuerySuspense in its subtree render on the server, so a query you left out runs its queryFn during the server render and fails there. A component the server does not render (an inactive tab, a closed dialog) doesn't count.
  • Use fetchQuery for the read the page cannot render without. prefetchQuery swallows failures, so a failed prefetch still sends the page, with an error card in the HTML that the browser then throws away. fetchQuery throws instead, so the page render fails.

No prop can switch this on. PrefetchedQueries derives the flag QuerySuspense reads from the dehydrated payload, so a prefetch that produced nothing keeps the client-only behaviour.

To build the server QueryClient yourself (for example with shared defaultOptions), pass createQueryClient. QueryHydrationBoundary forwards dehydrateOptions to TanStack's dehydrate.

If you already have a dehydrated state from elsewhere, render PrefetchedQueries from @zeno-lib/query/prefetched-queries directly. It is TanStack's HydrationBoundary plus the flag.

Timing presets

Spread a preset as the first property of queryOptions instead of inventing a staleTime:

import { cachedQueryDefaults } from "@zeno-lib/query/query-defaults"
import { queryOptions } from "@tanstack/react-query"

export const countriesQueryOptions = () =>
  queryOptions({
    ...cachedQueryDefaults,
    queryFn: fetchCountries,
    queryKey: ["countries"],
  })
ExportstaleTimerefetchIntervalTypical use
liveBackgroundQueryDefaults15 s15 s, also in the backgroundFast-moving data that keeps updating while the tab is hidden
liveQueryDefaults30 s30 sFast-moving data refreshed while it is on screen
shortLivedQueryDefaults1 minnoneData that can be slightly stale
longLivedQueryDefaults2 minnoneSlower-changing detail data
cachedQueryDefaults5 minnoneReference data; also keeps it cached for 30 min (gcTime)
staticQueryDefaultsnever stalenoneEffectively immutable data

Screenshots of loading and error states

In development and tests, debugState="loading" or debugState="error" on a QuerySuspense forces that UI without running the query, so you can capture it in a screenshot. QuerySuspense ignores it when NODE_ENV is "production", but remove it from source once you're done.