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

URL state and pagination

Put filters, sort and page in the URL, let the loader turn them into the query's input, and pick what shows while the new input loads.

Filters, sort order and pagination are search params. The loader turns them into the query's input, so every navigation starts the right query and the component only ever reads the handle it was given:

src/routes/posts.tsx
import { import zz } from "zod";

const 
const PostsSearch: z.ZodObject<{
    sort: z.ZodDefault<z.ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: z.ZodDefault<z.ZodInt>;
}, z.core.$strip>
PostsSearch
= import zz.
function object<{
    sort: z.ZodDefault<z.ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: z.ZodDefault<z.ZodInt>;
}>(shape?: {
    sort: z.ZodDefault<z.ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: z.ZodDefault<z.ZodInt>;
} | undefined, params?: string | {
    error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
    message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
    sort: z.ZodDefault<z.ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: z.ZodDefault<...>;
}, z.core.$strip>
object
({
sort: z.ZodDefault<z.ZodEnum<{
    newest: "newest";
    oldest: "oldest";
}>>
sort
: import zz.
enum<readonly ["newest", "oldest"]>(values: readonly ["newest", "oldest"], params?: string | z.core.$ZodEnumParams): z.ZodEnum<{
    newest: "newest";
    oldest: "oldest";
}> (+1 overload)
export enum
enum
(["newest", "oldest"]).
ZodType<any, any, $ZodEnumInternals<{ newest: "newest"; oldest: "oldest"; }>>.default(def: "newest" | "oldest"): z.ZodDefault<z.ZodEnum<{
    newest: "newest";
    oldest: "oldest";
}>> (+1 overload)
default
("newest"),
page: z.ZodDefault<z.ZodInt>page: import zz.function int(params?: string | z.core.$ZodCheckNumberFormatParams): z.ZodIntint()._ZodNumber<$ZodNumberInternals<number>>.positive(params?: string | z.core.$ZodCheckGreaterThanParams): z.ZodIntpositive().ZodType<any, any, $ZodNumberInternals<number>>.default(def: number): z.ZodDefault<z.ZodInt> (+1 overload)default(1), }); export const
const Route: Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", z.ZodObject<{
    sort: z.ZodDefault<z.ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: z.ZodDefault<z.ZodInt>;
}, z.core.$strip>, ResolveParams<"/posts">, AnyContext, AnyContext, AnyContext, {
    sort: "newest" | "oldest";
    page: number;
}, ... 5 more ..., undefined>
Route
=
createFileRoute<"/posts", RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts">(path?: "/posts" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
createFileRoute
("/posts")({
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 12 more ..., undefined>.validateSearch?: Constrain<z.ZodObject<{
    sort: z.ZodDefault<z.ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: z.ZodDefault<z.ZodInt>;
}, z.core.$strip>, AnyValidator, DefaultValidator>
validateSearch
:
const PostsSearch: z.ZodObject<{
    sort: z.ZodDefault<z.ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: z.ZodDefault<z.ZodInt>;
}, z.core.$strip>
PostsSearch
,
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 12 more ..., undefined>.loaderDeps?: ((opts: FullSearchSchemaOption<RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, z.ZodObject<{
    sort: z.ZodDefault<z.ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: z.ZodDefault<z.ZodInt>;
}, z.core.$strip>>) => {
    sort: "newest" | "oldest";
    page: number;
}) | undefined
loaderDeps
: (
args: FullSearchSchemaOption<RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, z.ZodObject<{
    sort: z.ZodDefault<z.ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: z.ZodDefault<z.ZodInt>;
}, z.core.$strip>>
args
) =>
args: FullSearchSchemaOption<RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, z.ZodObject<{
    sort: z.ZodDefault<z.ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: z.ZodDefault<z.ZodInt>;
}, z.core.$strip>>
args
.
FullSearchSchemaOption<RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, ZodObject<...>>.search: {
    sort: "newest" | "oldest";
    page: number;
}
search
,
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, ... 5 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<(args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", ResolveParams<"/posts">, {
    sort: "newest" | "oldest";
    page: number;
}, AnyContext, AnyContext, AnyContext, unknown, undefined>) => {
    posts: QueryHandle<(input: {
        page: number;
        sort: "newest" | "oldest";
    }) => QueryOptions<Flat<{
        data: readonly Post[];
        key: readonly [...];
    }>>>;
}, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader
: (
args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", ResolveParams<"/posts">, {
    sort: "newest" | "oldest";
    page: number;
}, ... 4 more ..., undefined>
args
) => ({
posts: QueryHandle<(input: {
    page: number;
    sort: "newest" | "oldest";
}) => QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>>
posts
:
args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", ResolveParams<"/posts">, {
    sort: "newest" | "oldest";
    page: number;
}, ... 4 more ..., undefined>
args
.
LoaderFnContext<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 7 more ..., undefined>.context: {
    queryClient: QueryClient;
}
context
.queryClient: QueryClient<never>queryClient.
QueryClient<never>.prefetch<(input: {
    page: number;
    sort: "newest" | "oldest";
}) => QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>>(factory: (input: {
    page: number;
    sort: "newest" | "oldest";
}) => QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>, input: {
    page: number;
    sort: "newest" | "oldest";
}): QueryHandle<(input: {
    page: number;
    sort: "newest" | "oldest";
}) => QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>>
prefetch
(
function postsQuery(input: {
    page: number;
    sort: "newest" | "oldest";
}): QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>
postsQuery
,
args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", ResolveParams<"/posts">, {
    sort: "newest" | "oldest";
    page: number;
}, ... 4 more ..., undefined>
args
.
LoaderFnContext<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, ... 5 more ..., undefined>, ... 7 more ..., undefined>.deps: {
    sort: "newest" | "oldest";
    page: number;
}
deps
),
}), UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function PostsPage(): React.JSX.ElementPostsPage, }); function function PostsPage(): React.JSX.ElementPostsPage() { const
const loaderData: {
    posts: QueryHandle<(input: {
        page: number;
        sort: "newest" | "oldest";
    }) => QueryOptions<Flat<{
        data: readonly Post[];
        key: readonly ["posts", {
            page: number;
            sort: "newest" | "oldest";
        }];
    }>>>;
}
loaderData
=
const Route: Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", z.ZodObject<{
    sort: z.ZodDefault<z.ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: z.ZodDefault<z.ZodInt>;
}, z.core.$strip>, ResolveParams<"/posts">, AnyContext, AnyContext, AnyContext, {
    sort: "newest" | "oldest";
    page: number;
}, ... 5 more ..., undefined>
Route
.
RouteExtensions<"/posts", "/posts">.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", true, unknown, boolean> & OptionalStructuralSharing<...>) | undefined) => {
    ...;
}
useLoaderData
();
const [const postsPromise: Thenable<readonly Post[]>postsPromise,
const postsState: QueryControls<(input: {
    page: number;
    sort: "newest" | "oldest";
}) => QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>>
postsState
] =
useQuery<(input: {
    page: number;
    sort: "newest" | "oldest";
}) => QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>, undefined>(factory: (input: {
    page: number;
    sort: "newest" | "oldest";
}) => QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>, handle: QueryHandle<(input: {
    page: number;
    sort: "newest" | "oldest";
}) => QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>>, options?: (Options & ... 1 more ... & {
    ...;
}) | undefined): readonly [promise: ...] (+1 overload)
useQuery
(
function postsQuery(input: {
    page: number;
    sort: "newest" | "oldest";
}): QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>
postsQuery
,
const loaderData: {
    posts: QueryHandle<(input: {
        page: number;
        sort: "newest" | "oldest";
    }) => QueryOptions<Flat<{
        data: readonly Post[];
        key: readonly ["posts", {
            page: number;
            sort: "newest" | "oldest";
        }];
    }>>>;
}
loaderData
.
posts: QueryHandle<(input: {
    page: number;
    sort: "newest" | "oldest";
}) => QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>>
posts
, {
Options.whilePending?: "previous" | "suspend" | undefinedwhilePending: "previous", }); const const posts: readonly Post[]posts = use<readonly Post[]>(usable: React.Usable<readonly Post[]>): readonly Post[]use(const postsPromise: Thenable<readonly Post[]>postsPromise); return ( <> <React.JSX.IntrinsicElements.nav: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>nav React.AriaAttributes["aria-busy"]?: Booleanish | undefinedaria-busy={
const postsState: QueryControls<(input: {
    page: number;
    sort: "newest" | "oldest";
}) => QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>>
postsState
.QueryControls<(input: { page: number; sort: "newest" | "oldest"; }) => QueryOptions<Flat<{ data: readonly Post[]; key: readonly ["posts", { page: number; sort: "newest" | "oldest"; }]; }>>>.isPrevious: booleanisPrevious}>
<const Link: LinkComponent<"a", string>Link OptionalToOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, "never", false, RouterHistory, Record<...>>, string, ".">.to?: "/posts" | "/login" | "/settings" | "/todos" | "/blog/$slug" | "/posts/$slug" | "/" | "." | ".." | "/dashboard" | undefinedto="."
MakeOptionalSearchParams<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, "never", false, RouterHistory, Record<...>>, string, ".">.search?: true | {
    sort?: "newest" | "oldest" | undefined;
    page?: number | undefined;
} | ParamsReducerFn<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>, "SEARCH", string, "."> | undefined
search
={(
prev: {
    sort?: "newest" | "oldest" | undefined;
    page?: number | undefined;
}
prev
) => ({ ...
prev: {
    sort?: "newest" | "oldest" | undefined;
    page?: number | undefined;
}
prev
, sort?: "newest" | "oldest" | undefinedsort: "newest" })}>
Newest </const Link: LinkComponent<"a", string>Link> <const Link: LinkComponent<"a", string>Link OptionalToOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, "never", false, RouterHistory, Record<...>>, string, ".">.to?: "/posts" | "/login" | "/settings" | "/todos" | "/blog/$slug" | "/posts/$slug" | "/" | "." | ".." | "/dashboard" | undefinedto="."
MakeOptionalSearchParams<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, "never", false, RouterHistory, Record<...>>, string, ".">.search?: true | {
    sort?: "newest" | "oldest" | undefined;
    page?: number | undefined;
} | ParamsReducerFn<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>, "SEARCH", string, "."> | undefined
search
={(
prev: {
    sort?: "newest" | "oldest" | undefined;
    page?: number | undefined;
}
prev
) => ({ ...
prev: {
    sort?: "newest" | "oldest" | undefined;
    page?: number | undefined;
}
prev
, sort?: "newest" | "oldest" | undefinedsort: "oldest" })}>
Oldest </const Link: LinkComponent<"a", string>Link> </React.JSX.IntrinsicElements.nav: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>nav> <
function PostList(props: {
    readonly dimmed: boolean;
    readonly posts: ReadonlyArray<Post>;
}): React.JSX.Element
PostList
posts: readonly Post[]posts={const posts: readonly Post[]posts} dimmed: booleandimmed={
const postsState: QueryControls<(input: {
    page: number;
    sort: "newest" | "oldest";
}) => QueryOptions<Flat<{
    data: readonly Post[];
    key: readonly ["posts", {
        page: number;
        sort: "newest" | "oldest";
    }];
}>>>
postsState
.QueryControls<(input: { page: number; sort: "newest" | "oldest"; }) => QueryOptions<Flat<{ data: readonly Post[]; key: readonly ["posts", { page: number; sort: "newest" | "oldest"; }]; }>>>.isPrevious: booleanisPrevious} />
</> ); }

Clicking a link navigates, the loader prefetches { sort: "oldest", page: 1 } without blocking, and the component receives a new handle.

What shows while the new input loads

That's up to whilePending:

whilePendingWhile the new input loadsUse for
"suspend" (default)use() suspends. TanStack Router and Next.js run navigations as React transitions, so React keeps the whole previous screen, including the old URL's active links, until the data arrivesShort loads, and pages that shouldn't show stale data
"previous"The navigation commits immediately. The promise keeps the previous input's data and isPrevious is true until the new data landsLists, filters, pagination — anything that should feel instant

Either way, an input that's already cached renders immediately. A component that mounts with a pending handle has no previous data, so "previous" suspends like "suspend" on first load.

Page through the demo with each mode. Watch the address bar: with "suspend" it waits for the data, with "previous" it updates at once and the old page dims. Hovering Next prefetches the next page, so with a bit of patience neither mode waits.

This replaces TanStack Query's placeholderData: keepPreviousData and isPlaceholderData.

Skeletons for unrelated content

To show a skeleton instead, for instance when moving between unrelated posts, give the Suspense boundary the query's key. React treats it as new content and shows its fallback, even inside a transition:

// This loader prefetches the post instead of ensuring it.
const [const postPromise: Thenable<Post>postPromise, 
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: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>, options?: (Options & {
    readonly throwOnError?: never;
} & {
    readonly throwOnError?: undefined;
}) | undefined): readonly [promise: Thenable<Post>, controls: QueryControls<...>] (+1 overload)
useQuery
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
,
const loaderData: {
    post: QueryHandle<typeof postQuery>;
}
loaderData
.
post: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
);
return ( <const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.Attributes.key?: React.Key | null | undefinedkey={
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]; }>>>.hash: stringhash} React.SuspenseProps.fallback?: ReactNodefallback={<function PostSkeleton(): ReactNodePostSkeleton />}>
<
function PostBody(props: {
    readonly post: Promise<unknown>;
}): ReactNode
PostBody
post: Promise<unknown>post={const postPromise: Thenable<Post>postPromise} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> );

hash is the stable string form of the handle's key.

Pagination versus infinite scroll

They differ in where the page lives:

  • Pagination: the page is a search param and part of the input, so each page is its own query and the URL links to it.
  • Infinite scroll: filters and sort are search params, and pages are loaded into one paged query per input. Changing a filter starts a new list from its first page. See Infinite queries.

Why not local state?

It's tempting to keep the page number in useState and build the input from it. Don't: the loader can't see component state, so the query would start only after render, the back button wouldn't restore the page, and a shared link would open page one. Keeping the input in the URL makes every view linkable and every query startable before render.

In Next.js the same rules apply: read searchParams in the page, prefetch with them, and pass the handle to the client component.