Skip to content

raf-query is early. The API can still change between minor releases, so use it at your own risk.

raf-query

Server rendering

SSR and hydration

How the server's query cache streams to the browser, pending queries included, and how the browser reconciles it with its own cache.

With server rendering, the queries a page needs run on the server, and the HTML arrives with the data already in it. raf-query streams the server's cache to the browser as entries settle, so the browser starts with the same data and doesn't fetch it again.

Your components never deal with this. A handle only carries a key, and the browser's cache has the entry by the time anything reads it. There is no initialData to thread through props, and no hydration boundary to place around each component.

What happens on a request

  1. The server creates a query client for the request. Each request gets its own, so users never see each other's data.
  2. Loaders or server components start queries. ensure and fetch wait; prefetch doesn't.
  3. React renders and streams HTML. Components reading a pending query suspend, and their Suspense fallbacks go out first.
  4. The cache streams alongside the HTML. Each entry is sent once, as soon as it settles. Entries still pending when the shell is sent cross as promises, which resolve in the browser when the server's request finishes.
  5. The browser hydrates. Its query client receives each entry. A pending entry resumes the server's request instead of starting a new one, so a query never runs twice.
  6. The request ends. The server's client is cleared and dropped.

Server clients

A client on the server behaves differently, because it renders once:

BehaviorBrowserServer
Retries3 by defaultNone by default
gcTime5 minutes by defaultInfinity: the cache lives as long as the request
Stale cached dataRefetched in the backgroundReused, unless marked by invalidate
Focus and reconnectRefetch read, stale queriesNot watched

So in a server render, ensure and fetch wait for the same thing, and each query makes at most one request whatever its staleTime.

Errors

Only entries with data or a request in flight are sent. A query that had already failed when its entry would have been sent stays on the server, and the browser fetches it again when something reads it.

A query sent while pending, that then fails on the server, rejects in the browser. By default it rejects with a RedactedError that names the query but not the message, so server internals (SQL errors, stack traces) don't leak into the page. The browser treats it like any failed load: the reader throws to its error boundary, and resetting the boundary refetches the query from the browser. Pass keepErrors: true in the integration's dehydrateOptions to send the original error instead.

Browser-only queries

Some data only the browser can know: a push subscription, a notification permission, a service worker, a media query. That still belongs in the cache — same factory, same handle, same invalidation — just disable it where window doesn't exist, as in TanStack Query, so the server never runs it:

export function 
function pushStateQuery(input: {
    channel: string;
}): QueryOptions<Flat<{
    data: "on" | "off";
    key: readonly ["pushState", string];
}>>
pushStateQuery
(
input: {
    channel: string;
}
input
: { channel: stringchannel: string }) {
return
queryOptions<"on" | "off", unknown, never, readonly ["pushState", string], undefined>(options: Omit<QueryOptions<{
    data: "on" | "off";
    error: unknown;
    key: readonly ["pushState", string];
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
    data: "on" | "off";
    key: readonly ["pushState", string];
}>>
queryOptions
({
queryKey: readonly ["pushState", string]queryKey: ["pushState",
input: {
    channel: string;
}
input
.channel: stringchannel],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
    data: "on" | "off";
    error: unknown;
    key: readonly ["pushState", string];
    services: never;
}>
queryFn
: () => function detectPushState(channel: string): Promise<"on" | "off">detectPushState(
input: {
    channel: string;
}
input
.channel: stringchannel),
enabled?: boolean | undefinedenabled: typeof var window: Window & typeof globalThiswindow !== "undefined", }); }

A server client never starts a disabled query and never sends it. Loaders and components still use it like any query: prefetch returns a handle on the server too, and the browser starts the request when the loader or the first read runs there. On the server, readers see it pending:

function function PushState(): React.JSX.ElementPushState() {
	const [const pushPromise: Thenable<"on" | "off">pushPromise, 
const push: QueryControls<(input: {
    channel: string;
}) => QueryOptions<Flat<{
    data: "on" | "off";
    key: readonly ["pushState", string];
}>>>
push
] =
useQuery<(input: {
    channel: string;
}) => QueryOptions<Flat<{
    data: "on" | "off";
    key: readonly ["pushState", string];
}>>, undefined>(factory: (input: {
    channel: string;
}) => QueryOptions<Flat<{
    data: "on" | "off";
    key: readonly ["pushState", string];
}>>, handle: QueryHandle<(input: {
    channel: string;
}) => QueryOptions<Flat<{
    data: "on" | "off";
    key: readonly ["pushState", string];
}>>>, options?: (Options & {
    readonly throwOnError?: never;
} & {
    readonly throwOnError?: undefined;
}) | undefined): readonly [promise: ...] (+1 overload)
useQuery
(
function pushStateQuery(input: {
    channel: string;
}): QueryOptions<Flat<{
    data: "on" | "off";
    key: readonly ["pushState", string];
}>>
pushStateQuery
,
const loaderData: {
    push: import("@raf-query/react").QueryHandle<typeof pushStateQuery>;
}
loaderData
.
push: QueryHandle<(input: {
    channel: string;
}) => QueryOptions<Flat<{
    data: "on" | "off";
    key: readonly ["pushState", string];
}>>>
push
);
if (
const push: QueryControls<(input: {
    channel: string;
}) => QueryOptions<Flat<{
    data: "on" | "off";
    key: readonly ["pushState", string];
}>>>
push
.QueryControls<(input: { channel: string; }) => QueryOptions<Flat<{ data: "on" | "off"; key: readonly ["pushState", string]; }>>>.isPending: booleanisPending) {
return <
function PushSwitch(props: {
    readonly state: "on" | "off" | "unknown";
}): ReactNode
PushSwitch
state: "on" | "off" | "unknown"state="unknown" />;
} const const state: "on" | "off"state = use<"on" | "off">(usable: React.Usable<"on" | "off">): "on" | "off"use(const pushPromise: Thenable<"on" | "off">pushPromise); return <
function PushSwitch(props: {
    readonly state: "on" | "off" | "unknown";
}): ReactNode
PushSwitch
state: "on" | "off" | "unknown"state={const state: "on" | "off"state} />;
}

The browser hydrates the same pending state, then renders the result. A reader that calls use() on the promise instead needs a Suspense boundary: on the server the promise rejects with Query.DisabledError, so React sends the fallback and renders the boundary in the browser. React reports that boundary to onRecoverableError while hydrating.

ensure and fetch can't wait for it on the server, so they fail with Query.DisabledError. Use prefetch in loaders that also run on the server. invalidate, reset, refetch and the paging controls do nothing there.

When the browser already has data

On client-side navigations in Next.js, server components render again and send their cache entries with the RSC payload, and React Router's loaders return a new snapshot, while the browser may already have the same entries. The browser takes whichever copy is newer, by when it was fetched (updatedAt; server and browser clocks should agree):

  • No data, no request of its own: it resumes the server's pending request.
  • Data, or its own request in flight: it waits for the server's copy and takes it if newer.
  • If it keeps its own copy (the server's is older, as in a payload the router replays from its cache on back and forward, or the server's request failed), it refetches it if stale.

Serialization

Data, keys, inputs and meta cross the wire, so they must be serializable by the framework's serializer. TanStack Start uses Seroval, Next.js uses React's RSC serialization, and React Router uses turbo-stream: all three handle Date, Map, Set, BigInt and promises. The integration's serializeData and deserializeData options transform data on the way, for anything else. With React Router, pass them to dehydrate and to HydrationBoundary's options.

Only send what's serializable. These docs, for instance, cache each demo's code module as a query, and a module full of components can't cross the wire. The router integration's filter option keeps those entries out of the stream. A filter replaces the default one, canDehydrate (entries with data or a request in flight), so combine the two:

setupRouterIntegration<AnyRouter>(options: RouterIntegrationOptions<AnyRouter>): voidsetupRouterIntegration({
	RouterIntegrationOptions<TRouter extends AnyRouter>.dehydrateOptions?: DehydrateOptions | undefineddehydrateOptions: {
		DehydrateOptions.filter?: ((query: Query) => boolean) | undefinedfilter: (query: Query<QueryFactory>query) => query: Query<QueryFactory>query.Query<QueryFactory>.key: QueryKeykey[0] === "doc" && canDehydrate<QueryFactory>(query: Query<QueryFactory>): booleancanDehydrate(query: Query<QueryFactory>query),
	},
	RouterIntegrationOptions<TRouter extends AnyRouter>.queryClient: QueryClient<never>queryClient,
	RouterIntegrationOptions<AnyRouter>.router: AnyRouterrouter,
});

Integrations

  • TanStack Start: setupRouterIntegration streams the router's query client.
  • React Server Components: QueryProvider and HydrationBoundary for the Next.js App Router and other RSC frameworks.
  • React Router: HydrationBoundary for the snapshot each Framework Mode loader returns.