Skip to content

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

raf-query

Guides

Query factories

Define each query once as a factory of one serializable input, shared by loaders, server components and components.

A query is a factory: a function of zero or one argument that returns queryOptions(...) or infiniteQueryOptions(...). The loader and the component call the same factory, so data types flow end to end.

src/queries.ts
import { 
function infiniteQueryOptions<TPage, TPageParam, TError = unknown, TServices = never, const TKey extends QueryKey = QueryKey, TThrow extends boolean | undefined = undefined>(options: Omit<InfiniteQueryOptions<{
    error: TError;
    key: TKey;
    page: TPage;
    pageParam: TPageParam;
    services: TServices;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<TThrow>): InfiniteQueryOptions<Flat<{
    key: TKey;
    page: TPage;
    pageParam: TPageParam;
} & ErrorPart<TError> & ServicesPart<TServices> & ThrowPart<...>>>
infiniteQueryOptions
,
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 function
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>
commentsQuery
(
input: {
    slug: string;
}
input
: { slug: stringslug: string }) {
return
infiniteQueryOptions<CommentPage, number, unknown, never, readonly ["comments", string], undefined>(options: Omit<InfiniteQueryOptions<{
    error: unknown;
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>
infiniteQueryOptions
({
queryKey: readonly ["comments", string]queryKey: ["comments",
input: {
    slug: string;
}
input
.slug: stringslug],
function queryFn(context: PageFunctionContext<number>): QueryFnResult<{
    data: CommentPage;
    error: unknown;
    services: never;
}>
queryFn
: (context: PageFunctionContext<number>context) =>
function listComments(input: {
    cursor: number;
    slug: string;
}): Promise<CommentPage>
listComments
({ slug: stringslug:
input: {
    slug: string;
}
input
.slug: stringslug, cursor: numbercursor: context: PageFunctionContext<number>context.PageFunctionContext<number>.pageParam: numberpageParam }),
initialPageParam: numberinitialPageParam: 0, function getNextPageParam(lastPage: CommentPage, pages: readonly CommentPage[], lastPageParam: number, pageParams: readonly number[]): number | null | undefinedgetNextPageParam: (lastPage: CommentPagelastPage) => lastPage: CommentPagelastPage.CommentPage.nextCursor: number | nullnextCursor, }); }

One input, serializable

Factories take at most one argument; the type rejects two or more. That argument is the query's input, and it must be serializable, because it's what a handle carries from the loader (or the server) to the component.

Use an object even for one field ({ slug }, { id }): it reads well at call sites and leaves room to grow.

const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<(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;
}): QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
prefetch
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
, { slug: stringslug: "hello-world" });
const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<() => InfiniteQueryOptions<Flat<{
    key: readonly ["issues"];
    page: {
        items: Array<never>;
        nextCursor: null;
    };
    pageParam: number;
}>>>(factory: () => InfiniteQueryOptions<Flat<{
    key: readonly ["issues"];
    page: {
        items: Array<never>;
        nextCursor: null;
    };
    pageParam: number;
}>>, input?: undefined): QueryHandle<() => InfiniteQueryOptions<Flat<{
    key: readonly ["issues"];
    page: {
        items: Array<never>;
        nextCursor: null;
    };
    pageParam: number;
}>>>
prefetch
(
function issuesQuery(): InfiniteQueryOptions<Flat<{
    key: readonly ["issues"];
    page: {
        items: Array<never>;
        nextCursor: null;
    };
    pageParam: number;
}>>
issuesQuery
); // a factory without input

A factory with two parameters doesn't type-check where it's used:

function 
function postPageQuery(slug: string, page: number): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string, number];
}>>
postPageQuery
(slug: stringslug: string, page: numberpage: number) {
return
queryOptions<Post, unknown, never, readonly ["post", string, number], undefined>(options: Omit<QueryOptions<{
    data: Post;
    error: unknown;
    key: readonly ["post", string, number];
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string, number];
}>>
queryOptions
({
queryKey: readonly ["post", string, number]queryKey: ["post", slug: stringslug, page: numberpage],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
    data: Post;
    error: unknown;
    key: readonly ["post", string, number];
    services: never;
}>
queryFn
: () => function getPost(slug: string): Promise<Post>getPost(slug: stringslug),
}); } const qc: QueryClient<never>qc.QueryClient<never>.prefetch<QueryFactory<never>>(factory: QueryFactory<never>, input: unknown): QueryHandle<QueryFactory<never>>prefetch(postPageQuery, "hello-world");
Argument of type '(slug: string, page: number) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string, number]; }>>' is not assignable to parameter of type 'QueryFactory<never>'. Target signature provides too few arguments. Expected 2 or more, but got 1.

The factory decides everything

The factory is the only place a query is declared, including whether it's paged. Every other API — prefetch, ensure, fetch, useQuery, useWaterfall, invalidate, setData — takes either kind and infers the rest from the factory's type: the data, the input, and for paged queries the page and page param types.

Options set in the factory override the client's config.defaults:

OptionWhat it does
queryKeyThe cache key; include the input. See Query keys
queryFnLoads the data. See Query functions
staleTimeHow long data counts as fresh, in ms
gcTimeHow long an unread entry stays cached, in ms
retry, retryDelaySee Retries
refetchOnWindowFocus, refetchOnReconnectSee Window focus refetching
structuralSharingSee Render optimizations
enabledfalse keeps the query from running. See Disabling a query. Default true
metaAnything you want to know about the query elsewhere. See Query meta

infiniteQueryOptions also takes initialPageParam, getNextPageParam, getPreviousPageParam and maxPages. See Infinite queries.

Disabling a query

As in TanStack Query, set enabled: false to keep a query from running, such as a feature behind a flag:

export function 
function invoicesQuery(input: {
    orgId: string;
}): QueryOptions<Flat<{
    data: readonly Invoice[];
    key: readonly ["invoices", string];
}>>
invoicesQuery
(
input: {
    orgId: string;
}
input
: { orgId: stringorgId: string }) {
return
queryOptions<readonly Invoice[], unknown, never, readonly ["invoices", string], undefined>(options: Omit<QueryOptions<{
    data: readonly Invoice[];
    error: unknown;
    key: readonly ["invoices", string];
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
    data: readonly Invoice[];
    key: readonly ["invoices", string];
}>>
queryOptions
({
queryKey: readonly ["invoices", string]queryKey: ["invoices",
input: {
    orgId: string;
}
input
.orgId: stringorgId],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
    data: readonly Invoice[];
    error: unknown;
    key: readonly ["invoices", string];
    services: never;
}>
queryFn
: () => function listInvoices(orgId: string): Promise<ReadonlyArray<Invoice>>listInvoices(
input: {
    orgId: string;
}
input
.orgId: stringorgId),
enabled?: boolean | undefinedenabled:
const flags: {
    readonly billing: boolean;
}
flags
.billing: booleanbilling,
}); }

Every call runs the factory again, so enabled is decided per call: a later call that returns true, such as the loader running after the flag turned on, starts the query. While it's disabled:

  • Nothing runs it: not prefetch, not its readers, not invalidate or refetch(). ensure and fetch fail with Query.DisabledError.
  • Readers see isPending. In the browser its promise stays pending, so a reader that calls use() shows its Suspense fallback until the query is enabled and loads.

enabled: typeof window !== "undefined" disables a query on the server only. See Browser-only queries.

Type inference

TypeScript infers the data type from queryFn. For paged queries it infers the page type from queryFn too, which means queryFn must come before getNextPageParam in the object literal. If your lint setup sorts keys, annotate the page instead:

const 
const commentsOptions: InfiniteQueryOptions<Flat<{
    key: readonly ["comments", "hello-world"];
    page: CommentPage;
    pageParam: number;
}>>
commentsOptions
=
infiniteQueryOptions<CommentPage, number, unknown, never, readonly ["comments", "hello-world"], undefined>(options: Omit<InfiniteQueryOptions<{
    error: unknown;
    key: readonly ["comments", "hello-world"];
    page: CommentPage;
    pageParam: number;
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", "hello-world"];
    page: CommentPage;
    pageParam: number;
}>>
infiniteQueryOptions
({
function getNextPageParam(lastPage: CommentPage, pages: readonly CommentPage[], lastPageParam: number, pageParams: readonly number[]): number | null | undefinedgetNextPageParam: (lastPage: CommentPagelastPage: CommentPage) => lastPage: CommentPagelastPage.CommentPage.nextCursor: number | nullnextCursor, initialPageParam: numberinitialPageParam: 0,
function queryFn(context: PageFunctionContext<number>): QueryFnResult<{
    data: CommentPage;
    error: unknown;
    services: never;
}>
queryFn
: (context: PageFunctionContext<number>context) =>
function listComments(input: {
    cursor: number;
    slug: string;
}): Promise<CommentPage>
listComments
({ cursor: numbercursor: context: PageFunctionContext<number>context.PageFunctionContext<number>.pageParam: numberpageParam, slug: stringslug: "hello-world" }),
queryKey: readonly ["comments", "hello-world"]queryKey: ["comments", "hello-world"], }); commentsOptions;
const commentsOptions: InfiniteQueryOptions<Flat<{
    key: readonly ["comments", "hello-world"];
    page: CommentPage;
    pageParam: number;
}>>

The options' types take a bag of named types rather than a list of type arguments. A promise or plain queryFn leaves error out of that bag (ErrorOf is unknown) and leaves services out when it needs none. An Effect queryFn adds the error it infers, and services when it needs any. The bag also keeps the key's shape as key (readonly ["post", string]), which keeps handles of two factories with the same input and data apart. Annotating options with a bag that has no key, as here, accepts any key:

import type { interface InfiniteQueryOptions<TTypes extends InfiniteQueryTypes>InfiniteQueryOptions, interface QueryOptions<TTypes extends QueryTypes>QueryOptions } from "@raf-query/react";

const 
const post: QueryOptions<{
    data: Post;
}>
post
: interface QueryOptions<TTypes extends QueryTypes>QueryOptions<{ data: Postdata: Post }> =
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
({ slug: stringslug: "hello-world" });
const
const comments: InfiniteQueryOptions<{
    page: CommentPage;
    pageParam: number;
}>
comments
: interface InfiniteQueryOptions<TTypes extends InfiniteQueryTypes>InfiniteQueryOptions<{ page: CommentPagepage: CommentPage; pageParam: numberpageParam: number }> =
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>
commentsQuery
({ slug: stringslug: "hello-world" });

To name a factory's types elsewhere:

import type { 
type DataOf<TFactory extends QueryFactory> = ReturnType<TFactory> extends InfiniteQueryOptions<infer TTypes extends InfiniteQueryTypes> ? InfiniteData<{
    page: TTypes["page"];
    pageParam: TTypes["pageParam"];
}> : ReturnType<TFactory> extends QueryOptions<infer TTypes extends QueryTypes> ? TTypes["data"] : unknown
DataOf
, type InputOf<TFactory extends QueryFactory> = Parameters<TFactory> extends [] ? undefined : Parameters<TFactory>[0]InputOf, interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle } from "@raf-query/react";
type PostData =
type DataOf<TFactory extends QueryFactory> = ReturnType<TFactory> extends InfiniteQueryOptions<infer TTypes extends InfiniteQueryTypes> ? InfiniteData<{
    page: TTypes["page"];
    pageParam: TTypes["pageParam"];
}> : ReturnType<TFactory> extends QueryOptions<infer TTypes extends QueryTypes> ? TTypes["data"] : unknown
DataOf
<typeof
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
>; // what queryFn resolves to
type PostData = Post
type PostInput = type InputOf<TFactory extends QueryFactory> = Parameters<TFactory> extends [] ? undefined : Parameters<TFactory>[0]InputOf<typeof
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
>;
type PostInput = {
    slug: string;
}
type
type PostHandle = QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
PostHandle
= interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle<typeof
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
>; // for props

Factory makers

A library can wrap a typed API in a function that returns a factory, generic over the endpoint. Call it inline, wherever a factory goes; the types flow from the endpoint:

function 
function endpointQuery<TArgs, TData>(endpoint: Endpoint<TArgs, TData>): (args: TArgs) => QueryOptions<Flat<{
    data: TData;
    key: readonly [string, TArgs];
}>>
endpointQuery
<
function (type parameter) TArgs in endpointQuery<TArgs, TData>(endpoint: Endpoint<TArgs, TData>): (args: TArgs) => QueryOptions<Flat<{
    data: TData;
    key: readonly [string, TArgs];
}>>
TArgs
,
function (type parameter) TData in endpointQuery<TArgs, TData>(endpoint: Endpoint<TArgs, TData>): (args: TArgs) => QueryOptions<Flat<{
    data: TData;
    key: readonly [string, TArgs];
}>>
TData
>(endpoint: Endpoint<TArgs, TData>endpoint: interface Endpoint<TArgs, TData>Endpoint<
function (type parameter) TArgs in endpointQuery<TArgs, TData>(endpoint: Endpoint<TArgs, TData>): (args: TArgs) => QueryOptions<Flat<{
    data: TData;
    key: readonly [string, TArgs];
}>>
TArgs
,
function (type parameter) TData in endpointQuery<TArgs, TData>(endpoint: Endpoint<TArgs, TData>): (args: TArgs) => QueryOptions<Flat<{
    data: TData;
    key: readonly [string, TArgs];
}>>
TData
>) {
return (args: TArgsargs:
function (type parameter) TArgs in endpointQuery<TArgs, TData>(endpoint: Endpoint<TArgs, TData>): (args: TArgs) => QueryOptions<Flat<{
    data: TData;
    key: readonly [string, TArgs];
}>>
TArgs
) =>
queryOptions<TData, unknown, never, readonly [string, TArgs], undefined>(options: Omit<QueryOptions<{
    data: TData;
    error: unknown;
    key: readonly [string, TArgs];
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
    data: TData;
    key: readonly [string, TArgs];
}>>
queryOptions
({
queryKey: readonly [string, TArgs]queryKey: [endpoint: Endpoint<TArgs, TData>endpoint.Endpoint<TArgs, TData>.name: stringname, args: TArgsargs],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
    data: TData;
    error: unknown;
    key: readonly [string, TArgs];
    services: never;
}>
queryFn
: (): interface Promise<T>Promise<
function (type parameter) TData in endpointQuery<TArgs, TData>(endpoint: Endpoint<TArgs, TData>): (args: TArgs) => QueryOptions<Flat<{
    data: TData;
    key: readonly [string, TArgs];
}>>
TData
> => function call<TArgs, TData>(endpoint: Endpoint<TArgs, TData>, args: TArgs): Promise<TData>call(endpoint: Endpoint<TArgs, TData>endpoint, args: TArgsargs),
}); } const
const post: QueryHandleWithData<(args: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        title: string;
    };
    key: readonly [string, {
        slug: string;
    }];
}>>>
post
= await const qc: QueryClient<never>qc.
QueryClient<never>.ensure<(args: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        title: string;
    };
    key: readonly [string, {
        slug: string;
    }];
}>>>(factory: (args: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        title: string;
    };
    key: readonly [string, {
        slug: string;
    }];
}>>, input: {
    slug: string;
}): Promise<QueryHandleWithData<(args: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        title: string;
    };
    key: readonly [string, {
        slug: string;
    }];
}>>>> (+1 overload)
ensure
(
function endpointQuery<{
    slug: string;
}, {
    title: string;
}>(endpoint: Endpoint<{
    slug: string;
}, {
    title: string;
}>): (args: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        title: string;
    };
    key: readonly [string, {
        slug: string;
    }];
}>>
endpointQuery
(
const api: {
    posts: {
        get: Endpoint<{
            slug: string;
        }, {
            title: string;
        }>;
    };
}
api
.
posts: {
    get: Endpoint<{
        slug: string;
    }, {
        title: string;
    }>;
}
posts
.
get: Endpoint<{
    slug: string;
}, {
    title: string;
}>
get
), { slug: stringslug });
const [
const postPromise: {
    title: string;
}
postPromise
] =
useQuery<(args: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        title: string;
    };
    key: readonly [string, {
        slug: string;
    }];
}>>, undefined>(factory: (args: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        title: string;
    };
    key: readonly [string, {
        slug: string;
    }];
}>>, handle: QueryHandleWithData<(args: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        title: string;
    };
    key: readonly [string, {
        slug: string;
    }];
}>>>, options?: (Options & {
    readonly throwOnError?: never;
} & {
    readonly throwOnError?: undefined;
}) | undefined): readonly [data: ...] (+1 overload)
useQuery
(
function endpointQuery<{
    slug: string;
}, {
    title: string;
}>(endpoint: Endpoint<{
    slug: string;
}, {
    title: string;
}>): (args: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        title: string;
    };
    key: readonly [string, {
        slug: string;
    }];
}>>
endpointQuery
(
const api: {
    posts: {
        get: Endpoint<{
            slug: string;
        }, {
            title: string;
        }>;
    };
}
api
.
posts: {
    get: Endpoint<{
        slug: string;
    }, {
        title: string;
    }>;
}
posts
.
get: Endpoint<{
    slug: string;
}, {
    title: string;
}>
get
),
const post: QueryHandleWithData<(args: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        title: string;
    };
    key: readonly [string, {
        slug: string;
    }];
}>>>
post
);

Each call returns a new function, but entries are found by queryKey, so every call for the same endpoint and args reaches the same entry. Put everything the key depends on in it.

Server-only factories

A factory whose queryFn imports server-only code (a database client) can't be imported by a client component, so it can't be read with useQuery in the browser. Render its data in a server component, or call the database through a server function or an API route so the browser can refetch it. See React Server Components: server and browser requests and TanStack Start: server and browser requests.