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 start and 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 an input, 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 call load. swr and revalidate entries wait for their data; prefetch entries don'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 starts or settles. Entries still pending 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

createQueryClient makes a server client where window doesn't exist (set isServer to choose). A server client renders once, so it behaves differently:

BehaviorBrowserServer
RetriesWith the retry() linkNone, even with retry()
gcTime5 minutes by defaultNever collected: the cache lives as long as the request
Stale cached dataRefetched (swr entries resolve with it and refetch alongside)Reused: staleTime doesn't apply within a request; revalidate entries refetch invalidated data
Focus and reconnectRefetch read, stale queries, with their featureNot watched: features start when a provider mounts

So in a server render, every entry makes at most one request per query, whatever its staleTime, unless a write invalidates the query and a revalidate entry reads it again.

A server render fetches a query that nothing started, except where server components feed it: see Queries nothing started on the server.

The provider

Every framework provides the client with QueryClientProvider. One Providers component works in all of them: it takes the router's client when the router makes one, and otherwise creates its own, one per server render and one per browser tab:

src/components/providers.tsx
"use client";

import { type interface QueryClient<TTypes extends RootTypes = any>

A query cache. TTypes is its root's types, which factories built with bindQueryOptions<typeof makeQueryClient>() are checked against. The bare QueryClient type takes a client of any root.

QueryClient
,
function QueryClientProvider(props: {
    readonly children?: ReactNode;
    readonly client: QueryClient;
}): JSX.Element

Provides a client to the tree, and runs its features (focus and reconnect refetching, polling, …) while mounted.

QueryClientProvider
} from "@raf-query/react";
import { type type ReactNode = string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null | undefined

Represents all of the things React can render.

ReactNode
, function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)

Returns a stateful value, and a function to update it.

useState
} from "react";
import {
function makeQueryClient(): QueryClient<{
    readonly meta: Flat<Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<RetryMeta>>;
}>
makeQueryClient
} from "~/query-client.ts";
/** * The app's query client: the router's when it makes one, or one created * here, per server render and per browser tab. */ export function
function Providers(props: {
    readonly children: ReactNode;
    readonly client?: QueryClient;
}): JSX.Element

The app's query client: the router's when it makes one, or one created here, per server render and per browser tab.

Providers
(
props: {
    readonly children: ReactNode;
    readonly client?: QueryClient;
}
props
: {
readonly children: ReactNodechildren: type ReactNode = string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null | undefined

Represents all of the things React can render.

ReactNode
;
readonly client?: QueryClient<any> | undefinedclient?: interface QueryClient<TTypes extends RootTypes = any>

A query cache. TTypes is its root's types, which factories built with bindQueryOptions<typeof makeQueryClient>() are checked against. The bare QueryClient type takes a client of any root.

QueryClient
;
}) { const [
const client: QueryClient<any> | QueryClient<{
    readonly meta: Flat<Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<RetryMeta>>;
}>
client
] =
useState<QueryClient<any> | QueryClient<{
    readonly meta: Flat<Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<RetryMeta>>;
}>>(initialState: QueryClient<any> | QueryClient<{
    readonly meta: Flat<Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<RetryMeta>>;
}> | (() => QueryClient<...> | QueryClient<...>)): [...] (+1 overload)

Returns a stateful value, and a function to update it.

useState
(() =>
props: {
    readonly children: ReactNode;
    readonly client?: QueryClient;
}
props
.client?: QueryClient<any> | undefinedclient ??
function makeQueryClient(): QueryClient<{
    readonly meta: Flat<Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<RetryMeta>>;
}>
makeQueryClient
());
return ( <
function QueryClientProvider(props: {
    readonly children?: ReactNode;
    readonly client: QueryClient;
}): JSX.Element

Provides a client to the tree, and runs its features (focus and reconnect refetching, polling, …) while mounted.

QueryClientProvider
client: QueryClient<any>client={
const client: QueryClient<any> | QueryClient<{
    readonly meta: Flat<Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<RetryMeta>>;
}>
client
}>{
props: {
    readonly children: ReactNode;
    readonly client?: QueryClient;
}
props
.children: ReactNodechildren}</
function QueryClientProvider(props: {
    readonly children?: ReactNode;
    readonly client: QueryClient;
}): JSX.Element

Provides a client to the tree, and runs its features (focus and reconnect refetching, polling, …) while mounted.

QueryClientProvider
>
); }
FrameworkRenders
TanStack Start<Providers client={queryClient}> in the router's Wrap, with the client loaders use
Next.js<Providers> in the root layout. Server components use their own client per request
React Router<Providers> in the root route. Loaders use the request's client from middleware

"use client" makes it a client component in Next.js. Elsewhere the directive does nothing.

Queries nothing started on the server

A component can read a query that no loader or server component started: a waterfallHandle's query, or one the server's cache dropped after its loader ran. What the server render does with it depends on what carries the server's cache to the browser:

FrameworkA read of a query nothing started, on the server
TanStack StartFetches while rendering. setupRouterIntegration streams the entry with the rest of the cache, so the browser resumes it instead of fetching again
React RouterFetches while rendering, so the HTML has the data. A loader's snapshot carries only what loaders started, so the browser fetches the query again
Next.js and other RSC frameworksRenders the nearest Suspense fallback, and the query loads in the browser

With server components, the client components' server render has nothing to send its own requests through: only server components' HydrationBoundary sends entries. So that HydrationBoundary marks the client components' server render as it renders. Below it, a read of a query nothing sent doesn't start the request: its read rejects, use() throws, and React renders the nearest Suspense fallback in the HTML. The browser renders that boundary again, and its read starts the query there. Put a Suspense boundary around such readers, or start the query in a server component to have it in the HTML.

React sees that read as an error it recovers from. The error has the digest Next.js uses for ssr: false, so Next.js reports nothing. Other RSC frameworks report it to the server render's onError and, while hydrating, the browser's onRecoverableError.

The mark is set when the first HydrationBoundary renders. A client component that renders before it, such as one in a root layout above the page's boundary, still fetches on the server, and the browser fetches that query again.

Errors

The stream sends entries with data or with a request in flight. 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 crosses as a promise. If it throws on the server, the promise 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 with resetOne 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, with the same factory, handle and invalidation. Its factory is an ordinary one:

export function 
function pushStateQuery(input: {
    channel: string;
}): QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>
pushStateQuery
(
input: {
    channel: string;
}
input
: { channel: stringchannel: string }) {
return
queryOptions<readonly ["pushState", string], "on" | "off", "on" | "off">(options: BaseOptions<readonly ["pushState", string], DefaultTypes> & {
    readonly queryFn: (context: QueryFunctionContext<readonly ["pushState", string]>) => Awaitable<"on" | "off">;
    readonly select?: ((data: "on" | "off") => "on" | "off") | undefined;
}): QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes> (+1 overload)

Typed options for a query factory:

queryOptions
({
BaseOptions<readonly ["pushState", string], DefaultTypes>.queryKey: readonly ["pushState", string]

A plain array, usually built by a group(). Handles keep its literal type.

queryKey
:
const pushState: Group
<readonly [string]>(rest_0: string) => readonly ["pushState", string]
pushState
(
input: {
    channel: string;
}
input
.channel: stringchannel),
queryFn: (context: QueryFunctionContext<readonly ["pushState", string]>) => Awaitable<"on" | "off">queryFn: () => function detectPushState(channel: string): Promise<"on" | "off">detectPushState(
input: {
    channel: string;
}
input
.channel: stringchannel),
}); }

What keeps it off the server is where its handle comes from. A loader also runs on the server, so it has no entry for the query. The component builds the handle in the browser, after hydration, with waterfallHandle, and reading it starts the request:

function function PushSettings(): React.JSX.ElementPushSettings() {
	// `false` on the server and while hydrating, `true` after.
	const const hydrated: booleanhydrated = function useHydrated(): boolean

Return a boolean indicating if the JS has been hydrated already. When doing Server-Side Rendering, the result will always be false. When doing Client-Side Rendering, the result will always be false on the first render and true from then on. Even if a new component renders it will always start with true.

@returnsTrue if the JS has been hydrated already, false otherwise.
useHydrated
();
if (!const hydrated: booleanhydrated) { return <
function PushSwitch(props: {
    readonly state: "on" | "off" | "unknown";
}): ReactNode
PushSwitch
state: "unknown" | "on" | "off"state="unknown" />;
} return ( <const Suspense: React.ExoticComponent<React.SuspenseProps>

Lets you display a fallback until its children have finished loading.

Suspense
React.SuspenseProps.fallback?: ReactNode

A fallback react tree to show when a Suspense child (like React.lazy) suspends

fallback
={<
function PushSwitch(props: {
    readonly state: "on" | "off" | "unknown";
}): ReactNode
PushSwitch
state: "unknown" | "on" | "off"state="unknown" />}>
<function PushState(): React.JSX.ElementPushState /> </const Suspense: React.ExoticComponent<React.SuspenseProps>

Lets you display a fallback until its children have finished loading.

Suspense
>
); } function function PushState(): React.JSX.ElementPushState() { const [
const handle: QueryHandle<(input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>
handle
] =
useState<QueryHandle<(input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>>(initialState: QueryHandle<(input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>> | (() => QueryHandle<(input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>)): [...] (+1 overload)

Returns a stateful value, and a function to update it.

useState
(() =>
waterfallHandle<(input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>(factory: (input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>, input: {
    channel: string;
}): QueryHandle<(input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>> (+1 overload)

The handle of a query whose input is only known while rendering, like an id in another query's data. It starts nothing: the component that reads it fetches when it renders, which makes it a request waterfall, and the name keeps that visible. Prefer starting queries in a loader with load.

waterfallHandle
(
function pushStateQuery(input: {
    channel: string;
}): QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>
pushStateQuery
, { channel: stringchannel: "news" }),
); const [
const pushThenable: QueryValue<(input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>
pushThenable
] =
useQuery<(input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>(factory: (input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>, handle: QueryHandle<(input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>, options?: UseQueryOptions): readonly [...]

Read a query by its handle.

useQuery
(
function pushStateQuery(input: {
    channel: string;
}): QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>
pushStateQuery
,
const handle: QueryHandle<(input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>
handle
);
const const push: "on" | "off"push = use<"on" | "off">(usable: React.Usable<"on" | "off">): "on" | "off"use(
const pushThenable: QueryValue<(input: {
    channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>
pushThenable
);
return <
function PushSwitch(props: {
    readonly state: "on" | "off" | "unknown";
}): ReactNode
PushSwitch
state: "unknown" | "on" | "off"state={const push: "on" | "off"push} />;
}
  • TanStack Router's useHydrated() is false on the server and while the page hydrates, and true after. Until then, render what the server renders, so hydration matches: here, the switch with no state.
  • useQuery can't be called conditionally, so the query is read in a child component that renders only once hydrated.
  • The child builds the handle once, in useState's initializer, so every render reads the same handle.
  • The server never sees the handle, so it never runs the queryFn or sends the query. On a client-side navigation, useHydrated() is true from the first render, and the query starts as the component mounts.

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 a component reads it and it's stale.

Serialization

Data, keys and inputs 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. meta stays on the server: the browser reads it from its own factories.

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:

// Every demo query's key starts with this group.
const const demos: Group<readonly ["demos"]>demos = group<"demos">(name: "demos"): Group<readonly ["demos"]> (+1 overload)

A group, or a subgroup inside parent. Call it for a query's key: a flat, plain array that starts with the group's path. Invalidating a group covers its subgroups and every query below them.

group
("demos");
setupRouterIntegration<AnyRouter>(options: RouterIntegrationOptions<AnyRouter>): void

Connects a query client to a TanStack Router (and TanStack Start) router:

setupRouterIntegration
({
RouterIntegrationOptions<TRouter extends AnyRouter>.dehydrateOptions?: DehydrateOptions | undefineddehydrateOptions: { DehydrateOptions.filter?: ((entry: Entry) => boolean) | undefined

Which entries to send. Default: entries with data or a request in flight. An entry with neither has nothing to send and stays behind.

filter
: (entry: Entryentry) => entry: Entryentry.Entry.key: QueryKeykey[0] !== const demos: Group<readonly ["demos"]>demos.Group<readonly ["demos"]>.path: readonly ["demos"]

Its parents' names and its own.

path
[0] && function canDehydrate(entry: Entry): boolean

The default filter: entries with data or a request in flight.

canDehydrate
(entry: Entryentry),
}, RouterIntegrationOptions<TRouter extends AnyRouter>.queryClient: QueryClient<any>queryClient, RouterIntegrationOptions<AnyRouter>.router: AnyRouterrouter, });

Integrations

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

Without an integration, @raf-query/react has the pieces they use: dehydrate(client) snapshots the cache, dehydrateStream(client) snapshots it and then streams entries as they start and settle, and hydrate(client, state) writes a snapshot into a client. HydrationBoundary takes a snapshot as its state prop and hydrates it while rendering.