Skip to content

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

raf-query

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, ensure or fetch.
  • It passes handles to components. A handle is a key and an input, plus the data once ensure or fetch has 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 with use() 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 with use(), 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

src/queries.ts
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, }); }
src/routes/posts.$slug.tsx
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

PackageContents
@raf-query/coreFramework-agnostic: QueryClient (the cache with Promise methods), its Effect QueryCache, queryOptions / infiniteQueryOptions, handles, allKeyed, dehydrate / hydrate
@raf-query/reactHooks, providers, HydrationBoundary. Re-exports @raf-query/core, so apps import only this package

@raf-query/react has subpath entries for framework integrations and tooling:

EntryUse
@raf-query/react/tanstack-routerStreams the router's query cache to the client during SSR
@raf-query/react/devtoolsDevtools panel for inspecting the cache

Where next