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, 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 load, one entry per query: prefetch, swr or revalidate.
  • It passes handles to components. A handle is a query's key and input, with no data, so it serializes for free.
  • Components call useQuery(factory, handle), which returns a thenable and the query's state. A thenable is a promise that also carries status, value and reason, the fields use() reads: when the value is already there, use() returns it synchronously, and otherwise it suspends. A handle the loader waited for reads without suspending, and Suspense boundaries decide what shows while the rest is on its way.

Principles

  • Fetch where React recommends. Loaders and server components start queries. Components read them.
  • Thenables in, use() out. The read hook returns a stable thenable for every handle; components read 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.
  • Pay for what you use. The client does nothing on its own. Retries, refetching on focus or reconnect and polling are opt-in links and features, so an app ships only what it turns on.

A taste

src/queries.ts
import { function group<const TName extends string>(name: TName): Group<readonly [TName]> (+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
, const queryOptions: QueryOptionsBuilder<DefaultTypes>

Typed options for a query factory:

queryOptions
} from "@raf-query/react";
const const posts: Group<readonly ["posts"]>posts = group<"posts">(name: "posts"): Group<readonly ["posts"]> (+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
("posts");
export function
function postQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", string], Post, Post, DefaultTypes>
postQuery
(
input: {
    slug: string;
}
input
: { slug: stringslug: string }) {
return
queryOptions<readonly ["posts", string], Post, Post>(options: BaseOptions<readonly ["posts", string], DefaultTypes> & {
    readonly queryFn: (context: QueryFunctionContext<readonly ["posts", string]>) => Awaitable<Post>;
    readonly select?: ((data: Post) => Post) | undefined;
}): QueryOptions<readonly ["posts", string], Post, Post, DefaultTypes> (+1 overload)

Typed options for a query factory:

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

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

queryKey
:
const posts: Group
<readonly [string]>(rest_0: string) => readonly ["posts", string]
posts
(
input: {
    slug: string;
}
input
.slug: stringslug),
queryFn: (context: QueryFunctionContext<readonly ["posts", string]>) => Awaitable<Post>queryFn: () => function getPost(slug: string): Promise<Post>getPost(
input: {
    slug: string;
}
input
.slug: stringslug),
BaseOptions<TKey extends QueryKey, TTypes extends RootTypes = RootTypes>.staleTime?: number | undefined

How long data stays fresh, in ms. Default 0.

staleTime
: 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<...>

Creates a file-based Route factory for a given path.

@parampath — File path literal for the route (usually auto-generated).@returnsA function that accepts Route options and returns a Route instance.
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
) => {
const [
const handles: {
    readonly post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
}
handles
] = await
load<{
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}>(client: QueryClient, entries: {
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}): Promise<Loaded<{
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}>>

Start a loader's queries and return a serializable handle for each. It starts every entry before it waits for any, so the order of the entries can't create a waterfall. Resolves with [handles, data] once every swr and revalidate entry has data, and rejects with the error as soon as one fails. prefetch entries never make it reject. A skipToken in place of an entry starts nothing and gets no handle.

load
(
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<any>queryClient, {
post: LoadEntry<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">
post
:
swr<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(factory: (input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, ...args: LoadEntryArgs<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>): LoadEntry<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">

Use cached data even when it's stale, and wait only when the cache has none. Stale data resolves at once and refetches in the background, as HTTP's stale-while-revalidate does. The load rejects if the fetch it waits for fails.

swr
(
function postQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
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 }),
}); return
const handles: {
    readonly post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
}
handles
;
}, UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function PostPage(): React.JSX.ElementPostPage, }); function function PostPage(): React.JSX.ElementPostPage() { const [
const postThenable: QueryValue<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
postThenable
,
const postState: QueryState<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
postState
] =
useQuery<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(factory: (input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>, options?: UseQueryOptions): readonly [...]

Read a query by its handle.

useQuery
(
function postQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
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: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
post
,
); const const post: Postpost = use<Post>(usable: React.Usable<Post>): Postuse(
const postThenable: QueryValue<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
postThenable
);
return ( <React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article React.AriaAttributes["aria-busy"]?: Booleanish | undefinedaria-busy={
const postState: QueryState<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
postState
.QueryState<(input: { slug: string; }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>.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/reactHooks and providers. Re-exports @raf-query/core, so apps import this package rather than core
@raf-query/coreFramework-agnostic: the query client, its links and features, factories, load and handles, and the verbs

Apps import everything from @raf-query/react, hydration and the devtools included: what an app doesn't import stays out of its bundle. The one subpath, @raf-query/react/tanstack-router, streams the router's query cache to the browser during SSR.

Where next