Getting started
Overview
What raf-query is, the principles behind it, and how it relates to TanStack Query.
raf-query is a data-fetching library for React built around render-as-you-fetch. Queries start in route loaders and React Server Components, travel to components as small serializable handles, and are read with React's use() under Suspense. Fetch-on-render is an explicitly named escape hatch, not the default.
The cache's mechanics (dedupe, staleness, retries, infinite paging, SSR hydration) follow TanStack Query. The public API is built around loaders, RSC and use() instead of useQuery-in-render.
Motivation
Most React data fetching starts in a component: it renders, notices it needs data, and asks for it. Every nested component that does the same adds a round trip, so a page with a post, its comments and its author waits for three requests one after another. That's a waterfall, and it's the default shape of fetch-on-render code.
React's own guidance is to start fetching before rendering: in the router's loader, or in a server component. The page knows everything it needs from the URL, so every request can start at once, and components read the results as they arrive. raf-query is a cache shaped around that flow:
- The loader (or server component) starts queries with
prefetch,ensureorfetch. - It passes handles to components. A handle is a key and an input, plus the data once
ensureorfetchhas loaded it, so it serializes for free. - Components call
useQuery(factory, handle). It returns the data for a handle the loader waited for, and a promise to unwrap withuse()for one it didn't. Suspense boundaries decide what shows while data is on its way.
Principles
- Fetch where React recommends. Loaders and server components start queries. Components read them.
- Promises in,
use()out. The read hook returns a stable promise; components unwrap it withuse(), so reads can be conditional or happen in a child. - The URL is the input. Anything that changes what a query fetches (filters, sort, page) lives in path or search params, so the loader starts the right query on every navigation. Components never derive query input from local state.
- Actions for writes. Mutations are plain async functions in form actions or transitions. The cache provides invalidation, React provides pending and optimistic state.
A taste
import { function queryOptions<TData, TError = unknown, TServices = never, const TKey extends QueryKey = QueryKey, TThrow extends boolean | undefined = undefined>(options: Omit<QueryOptions<{
data: TData;
error: TError;
key: TKey;
services: TServices;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<TThrow>): QueryOptions<Flat<{
data: TData;
key: TKey;
} & ErrorPart<TError> & ServicesPart<TServices> & ThrowPart<TThrow>>>
queryOptions } from "@raf-query/react";
export function function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery(input: {
slug: string;
}
input: { slug: stringslug: string }) {
return queryOptions<Post, unknown, never, readonly ["post", string], undefined>(options: Omit<QueryOptions<{
data: Post;
error: unknown;
key: readonly ["post", string];
services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
queryOptions({
queryKey: readonly ["post", string]queryKey: ["post", input: {
slug: string;
}
input.slug: stringslug],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
data: Post;
error: unknown;
key: readonly ["post", string];
services: never;
}>
queryFn: () => function getPost(slug: string): Promise<Post>getPost(input: {
slug: string;
}
input.slug: stringslug),
staleTime?: number | undefinedstaleTime: 30_000,
});
}export const const Route: Route<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 15 more ..., undefined>
Route = createFileRoute<"/posts/$slug", Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, "/posts/$slug", "/$slug", "/posts/$slug">(path?: "/posts/$slug" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
createFileRoute("/posts/$slug")({
FilebaseRouteOptionsInterface<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<(args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, ... 8 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader: async (args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args) => ({
post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
post: await args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args.LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 7 more ..., undefined>.context: {
queryClient: QueryClient;
}
context.queryClient: QueryClient<never>queryClient.QueryClient<never>.ensure<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>(factory: (input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, input: {
slug: string;
}): Promise<QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>> (+1 overload)
ensure(function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery, {
slug: stringslug: args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args.LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 7 more ..., undefined>.params: {
slug: string;
}
params.slug: stringslug,
}),
}),
UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function PostPage(): React.JSX.ElementPostPage,
});
function function PostPage(): React.JSX.ElementPostPage() {
const [const post: Postpost, const postState: QueryControls<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
postState] = useQuery<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, undefined>(factory: (input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>, options?: (Options & {
readonly throwOnError?: never;
} & {
readonly throwOnError?: undefined;
}) | undefined): readonly [data: Post, controls: QueryControls<...>] (+1 overload)
useQuery(function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery, const Route: Route<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 15 more ..., undefined>
Route.RouteExtensions<"/posts/$slug", "/posts/$slug">.useLoaderData: <RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>, unknown, boolean>(opts?: (UseLoaderDataBaseOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, ... 8 more ..., undefined>, "never", false, RouterHistory, Record<...>>, "/posts/$slug", true, unknown, boolean> & OptionalStructuralSharing<...>) | undefined) => {
...;
}
useLoaderData().post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
post);
return (
<React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article React.AriaAttributes["aria-busy"]?: Booleanish | undefinedaria-busy={const postState: QueryControls<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
postState.QueryControls<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>.isFetching: booleanisFetching}>
<React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>{const post: Postpost.Post.title: stringtitle}</React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>
<React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>{const post: Postpost.Post.body: stringbody}</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
</React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article>
);
}The loader decides when the request starts and whether navigation waits for it; the component only reads. Try both strategies:
Packages
| Package | Contents |
|---|---|
@raf-query/core | Framework-agnostic: QueryClient (the cache with Promise methods), its Effect QueryCache, queryOptions / infiniteQueryOptions, handles, allKeyed, dehydrate / hydrate |
@raf-query/react | Hooks, providers, HydrationBoundary. Re-exports @raf-query/core, so apps import only this package |
@raf-query/react has subpath entries for framework integrations and tooling:
| Entry | Use |
|---|---|
@raf-query/react/tanstack-router | Streams the router's query cache to the client during SSR |
@raf-query/react/devtools | Devtools panel for inspecting the cache |
Where next
- Installation and the quick start get an app running.
- Coming from TanStack Query maps what you know onto raf-query.
- The guides start with important defaults, and each one has live demos you can poke at.
- What belongs in the cache covers backend data, browser-only queries, URL state and local UI state.