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

Coming from TanStack Query

How TanStack Query's APIs map onto raf-query, what stays the same, what changes about where queries start, and what we leave out on purpose.

raf-query's cache is modelled on TanStack Query's: query keys, request deduping, staleTime and gcTime, structural sharing, infinite pages, and opt-in retries and refetching on focus and reconnect. If you know how TanStack Query caches, you know how raf-query caches.

What changes is the API around the cache. TanStack Query lets any component start a query by rendering useQuery. raf-query moves the start to the route: loaders and server components start queries with load, and components only read them. Everything that changes the cache is a function that takes the client, such as invalidateMany(queryClient, posts), instead of a method on it.

API map

TanStack Queryraf-queryNotes
new QueryClient({ defaultOptions })createQueryClient({ ...recommended(), staleTime })Retries and refetching are links and features you opt into. Important defaults
queryOptions({ queryKey, queryFn })function q(input) { return queryOptions({ ... }) }A factory of one input, so handles can carry the input
queryClient.prefetchQuery() (not awaited)load(qc, { name: prefetch(factory, input) })Resolves with a handle. Starting queries
queryClient.ensureQueryData({ revalidateIfStale })load(qc, { name: swr(factory, input) })Resolves with a handle and the data
queryClient.fetchQuery()load(qc, { name: revalidate(factory, input) })Same, but waits for fresh data
useSuspenseQuery()use() on the thenable useQuery(factory, handle) returnsReading queries
useQuery() in render, starting the requestuseQuery(factory, waterfallHandle(factory, input))An escape hatch
useQuery() refetches stale data on mount (refetchOnMount: true)Reading never refetches; the loader decides freshnessStale data refreshes when the next loader starts it, on invalidation, focus, reconnect or polling. Important defaults
useQuery().data (doesn't suspend)switch (postThenable.status) on the thenable useQuery returnsReading without Suspense
enabled: false, skipTokenskipToken in place of a load entryIts handle is undefined; render the reader only with a handle. Conditional queries
enabled: typeof window !== "undefined"waterfallHandle in a component that renders once hydratedNo loader entry. Browser-only queries
useInfiniteQuery()useInfiniteQuery(factory, handle) with an infiniteQueryOptions factoryInfinite queries
placeholderData: keepPreviousDatauseQuery(f, h, { whilePending: "previous" })isPrevious instead of isPlaceholderData
throwOnErrorRead the thenable's status and reason instead of calling use()A first load's error rejects the thenable, and use() throws it to the nearest error boundary. Reading without Suspense
useMutation()Form Actions: await write(), then await invalidateMany(qc, [...])React provides pending and optimistic state
queryClient.invalidateQueries(filters)invalidateOne, invalidateMany, invalidateAllBy factory, group or where. Resolves when read queries have refetched. Query invalidation
queryClient.resetQueries / removeQueriesresetOne / resetMany / resetAll, removeOne / removeMany / removeAllThe same targets
queryClient.setQueryData / getQueryDatasetData(qc, factory, input, updater) / getData(qc, factory, input)
useIsFetching()useIsFetching(target?)Background indicators
HydrationBoundary + dehydrate()Automatic streaming with a router integrationSSR and hydration
cancelQueries()removeOne(...) / removeMany(...)Removing an entry aborts its request's signal
Register with queryMetameta typed by the client's links and featuresQuery meta

Loading data

Start queries in the loader

Where a TanStack Query app renders useQuery and lets it fetch, a raf-query route starts the query in its loader and passes the handle down. Pick the entry by what the route needs:

  • swr when the route can't render without the data. The loader waits for it, and use() returns its data without suspending.
  • prefetch when the UI can render without it. The loader doesn't wait, and use() suspends until the data lands.
  • skipToken in place of an entry when the route doesn't need the query, such as drafts only an admin sees. Its handle is undefined. See Conditional queries.
  • waterfallHandle only as a last resort. The component that reads it fetches when it renders, which is a request waterfall. It's also how a component reads data only the browser has, such as localStorage or the Permissions API, once the page has hydrated. See Browser-only queries.
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 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
;
const [
const handles: {
    readonly post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
    readonly comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
}
handles
] = await
load<{
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
    readonly comments: LoadEntry<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
}>(client: QueryClient, entries: {
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
    readonly comments: LoadEntry<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
}): Promise<...>

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, {
// Waits only if the post isn't in the cache yet.
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 }),
// Never waits: the comments load while the page renders.
comments: LoadEntry<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">
comments
:
prefetch<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>(factory: (input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, ...args: LoadEntryArgs<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>): LoadEntry<...>

Start the query if the cache has no fresh data, and don't wait for it. It never makes the load reject: errors show up where a component reads the handle.

prefetch
(
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery
, { slug: stringslug }),
}); return
const handles: {
    readonly post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
    readonly comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
}
handles
;
}, UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function PostPage(): React.JSX.ElementPostPage, });

Reading without Suspense

TanStack Query's non-suspending useQuery maps to switching on the status of the thenable raf-query's useQuery returns, instead of calling React's use() on it:

function 
function PostTitle(props: {
    handle: QueryHandle<typeof postQuery>;
}): React.JSX.Element
PostTitle
(
props: {
    handle: QueryHandle<typeof postQuery>;
}
props
: {
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
handle
: interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => { readonly queryFn: (context: never) => unknown; readonly queryKey: QueryKey; }>

A small serializable reference to a query: its key and its factory's input. It carries no data; readers get that from the cache. Handles of different factories have different types.

QueryHandle
<typeof
function postQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery
> }) {
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
,
props: {
    handle: QueryHandle<typeof postQuery>;
}
props
.
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
handle
);
switch (
const postThenable: QueryValue<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
postThenable
.status: "rejected" | "pending" | "fulfilled"status) {
case "pending": return <function Spinner(): React.ReactNodeSpinner />; case "rejected": return <
function ErrorNotice(props: {
    readonly error: unknown;
}): React.ReactNode
ErrorNotice
error: unknownerror={
const postThenable: Promise<Post> & {
    readonly reason: unknown;
    readonly status: "rejected";
}
postThenable
.reason: unknownreason} />;
case "fulfilled": return ( <React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1 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}>{
const postThenable: Promise<Post> & {
    readonly status: "fulfilled";
    readonly value: Post;
}
postThenable
.value: Postvalue.Post.title: stringtitle}</React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>
); } }
  • status is "pending", "fulfilled" or "rejected". value is the data once it's "fulfilled", and reason is the error once it's "rejected".
  • postState.isFetching is a background refetch, which keeps status at "fulfilled".
  • The component re-renders when the first load lands.
  • Reading status never throws to an error boundary, which suits optional UI.
  • Call use(postThenable) instead when you want Suspense. See Reading queries.

Side by side

A setting that shows the browser's notification permission. With TanStack Query, the component starts the query, and enabled keeps it off the server:

function function NotificationsSetting(): React.JSX.ElementNotificationsSetting() {
	const { const data: PermissionState | undefined

The last successfully resolved data for the query.

data
} = useQuery<PermissionState, Error, PermissionState, string[]>(options: UndefinedInitialDataOptions<PermissionState, Error, PermissionState, string[]>, queryClient?: QueryClient): UseQueryResult<PermissionState, Error> (+2 overloads)
@seequeryOptions to share these options between useQuery and imperative APIs like queryClient.query.@paramoptions — The UndefinedInitialDataOptions to use — everything you can pass to useQuery.@paramqueryClient — Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used.@returnsThe current query result. status is pending if there is no cached data to display, error if the last fetch attempt failed, or success if the query has data to display. isPending/isSuccess/isError are derived booleans for convenience.
useQuery
({
queryKey: string[]

The query key to use for this query.

queryKey
: ["permission", "notifications"],
queryFn?: unique symbol | QueryFunction<PermissionState, string[], never> | undefined

The function that the query will use to request data. Required, unless a default query function has been set via queryClient.setQueryDefaults or queryClient.setDefaultOptions. Receives a QueryFunctionContext. Must return a promise that will either resolve data or throw an error. The data cannot be undefined.

queryFn
: async () => {
const const status: PermissionStatusstatus = await var navigator: Navigator

The Window.navigator read-only property returns a reference to the Navigator object, which has methods and properties about the application running the script.

navigator
.Navigator.permissions: Permissions

The permissions read-only property of the Navigator interface returns a Permissions object that can be used to query and update permission status of APIs covered by the Permissions API.

permissions
.Permissions.query(permissionDesc: PermissionDescriptor): Promise<PermissionStatus>

The query() method of the Permissions interface returns the state of a user permission on the global scope.

query
({
PermissionDescriptor.name: PermissionNamename: "notifications", }); return const status: PermissionStatusstatus.PermissionStatus.state: PermissionState

The state read-only property of the PermissionStatus interface returns the state of a requested permission. This property returns one of 'granted', 'denied', or 'prompt'.

state
;
}, // Only the browser has `navigator.permissions`. enabled?: QueryBooleanOption<PermissionState, Error, PermissionState, string[]> | undefined

Set this to false or a function that returns false to disable automatic refetching when the query mounts or changes query keys. To refetch the query, use the refetch method returned from the useQuery instance. Accepts a boolean or function that returns a boolean.

enabled
: typeof var window: Window & typeof globalThis

The window property of a Window object points to the window object itself.

window
!== "undefined",
}); return <
function NotificationsToggle(props: {
    readonly permission: PermissionState | undefined;
}): React.ReactNode
NotificationsToggle
permission: PermissionState | undefinedpermission={const data: PermissionState | undefined

The last successfully resolved data for the query.

data
} />;
}

With raf-query, the factory is an ordinary one, and no loader starts it: a loader also runs on the server.

export function 
function permissionQuery(input: {
    name: PermissionName;
}): QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>
permissionQuery
(
input: {
    name: PermissionName;
}
input
: { name: PermissionNamename: type PermissionName = "camera" | "geolocation" | "microphone" | "midi" | "notifications" | "persistent-storage" | "push" | "screen-wake-lock" | "storage-access"PermissionName }) {
return
queryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState>(options: BaseOptions<readonly ["permission", PermissionName], DefaultTypes> & {
    readonly queryFn: (context: QueryFunctionContext<readonly ["permission", PermissionName]>) => Awaitable<PermissionState>;
    readonly select?: ((data: PermissionState) => PermissionState) | undefined;
}): QueryOptions<...> (+1 overload)

Typed options for a query factory:

queryOptions
({
BaseOptions<readonly ["permission", PermissionName], DefaultTypes>.queryKey: readonly ["permission", PermissionName]

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

queryKey
:
const permission: Group
<readonly [PermissionName]>(rest_0: PermissionName) => readonly ["permission", PermissionName]
permission
(
input: {
    name: PermissionName;
}
input
.name: PermissionNamename),
queryFn: (context: QueryFunctionContext<readonly ["permission", PermissionName]>) => Awaitable<PermissionState>queryFn: async () => { const const status: PermissionStatusstatus = await var navigator: Navigator

The Window.navigator read-only property returns a reference to the Navigator object, which has methods and properties about the application running the script.

navigator
.Navigator.permissions: Permissions

The permissions read-only property of the Navigator interface returns a Permissions object that can be used to query and update permission status of APIs covered by the Permissions API.

permissions
.Permissions.query(permissionDesc: PermissionDescriptor): Promise<PermissionStatus>

The query() method of the Permissions interface returns the state of a user permission on the global scope.

query
(
input: {
    name: PermissionName;
}
input
);
return const status: PermissionStatusstatus.PermissionStatus.state: PermissionState

The state read-only property of the PermissionStatus interface returns the state of a requested permission. This property returns one of 'granted', 'denied', or 'prompt'.

state
;
}, }); }

The handle is built in the browser instead. On the server and while the page hydrates, TanStack Router's useHydrated() is false, and the toggle renders without a permission. Once it's true, a child component builds a waterfallHandle once and reads it: useQuery can't be called conditionally, so the read lives in that child. Reading the handle starts the query, and the toggle re-renders with the permission. The child reads the thenable's status instead of calling use(), so it never suspends. The server never runs the queryFn.

function function NotificationsSetting(): React.JSX.ElementNotificationsSetting() {
	// `false` on the server and while hydrating, `true` after.
	const const hydrated: booleanhydrated = function useHydrated(): boolean

Return a boolean indicating if the JS has been hydrated already. When doing Server-Side Rendering, the result will always be false. When doing Client-Side Rendering, the result will always be false on the first render and true from then on. Even if a new component renders it will always start with true.

@returnsTrue if the JS has been hydrated already, false otherwise.
useHydrated
();
if (!const hydrated: booleanhydrated) { return <
function NotificationsToggle(props: {
    readonly permission: PermissionState | undefined;
}): React.ReactNode
NotificationsToggle
permission: PermissionState | undefinedpermission={var undefinedundefined} />;
} return <function NotificationsPermission(): React.JSX.ElementNotificationsPermission />; } function function NotificationsPermission(): React.JSX.ElementNotificationsPermission() { // Only the browser has `navigator.permissions`, so the handle is built here. const [
const handle: QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>>
handle
] =
useState<QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>>>(initialState: QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>> | (() => QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>>)): [...] (+1 overload)

Returns a stateful value, and a function to update it.

useState
(() =>
waterfallHandle<(input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>>(factory: (input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>, input: {
    name: PermissionName;
}): QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>> (+1 overload)

The handle of a query whose input is only known while rendering, like an id in another query's data. It starts nothing: the component that reads it fetches when it renders, which makes it a request waterfall, and the name keeps that visible. Prefer starting queries in a loader with load.

waterfallHandle
(
function permissionQuery(input: {
    name: PermissionName;
}): QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>
permissionQuery
, { name: PermissionNamename: "notifications" }),
); const [
const permissionThenable: QueryValue<(input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>>
permissionThenable
] =
useQuery<(input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>>(factory: (input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>, handle: QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>>, options?: UseQueryOptions): readonly [...]

Read a query by its handle.

useQuery
(
function permissionQuery(input: {
    name: PermissionName;
}): QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>
permissionQuery
,
const handle: QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>>
handle
);
const const permission: "denied" | "granted" | "prompt" | undefinedpermission =
const permissionThenable: QueryValue<(input: {
    name: PermissionName;
}) => QueryOptions<readonly ["permission", PermissionName], PermissionState, PermissionState, DefaultTypes>>
permissionThenable
.status: "rejected" | "pending" | "fulfilled"status === "fulfilled"
?
const permissionThenable: Promise<"denied" | "granted" | "prompt"> & {
    readonly status: "fulfilled";
    readonly value: "denied" | "granted" | "prompt";
}
permissionThenable
.value: "denied" | "granted" | "prompt"value
: var undefinedundefined; return <
function NotificationsToggle(props: {
    readonly permission: PermissionState | undefined;
}): React.ReactNode
NotificationsToggle
permission: PermissionState | undefinedpermission={const permission: "denied" | "granted" | "prompt" | undefinedpermission} />;
}

What stays the same

  • Query keys are arrays, hashed deterministically. See Query keys.
  • Caching: staleTime: 0 and a gcTime of five minutes by default, and structural sharing. See Important defaults.
  • recommended() behaves like TanStack Query's defaults: three retries with exponential backoff, and refetching read, stale queries on focus and reconnect.
  • Deduping: every way of starting a query joins a request already in flight for the same key.
  • select derives what readers get from the cached data.
  • Infinite queries keep { pages, pageParams }, with getNextPageParam, getPreviousPageParam and maxPages.

What's different

  • Nothing is on by default. A bare client doesn't retry, refetch on focus or reconnect, or poll. Spread recommended() for those, or pick the links and features you want.
  • No refetch on mount. Mounting a component that reads a query never fetches; whatever started the query decides. Loaders run on every navigation, so returning to a page still revalidates it.
  • The URL is the input. There is no option to change a read's input. Anything that changes what a query fetches lives in the route, so the loader starts the right query. URL state and pagination shows the pattern.
  • Queries are matched by factory or group, never by a key passed to a verb. invalidateMany(qc, postQuery) covers the queries that factory built. A group builds keys and covers every key that starts with its path, the way a TanStack Query key prefix does. A where target picks queries by meta or data, which is how an app writes tags.
  • meta is typed by the client. Links and features declare the meta fields they read, and factories built with queryOptions bound to that client are checked against them.
  • undefined is a valid result. A queryFn typed Promise<User | undefined> caches undefined like any other value. TanStack Query treats it as an error.
  • No placeholderData. Use whilePending: "previous" to keep showing the last page.
  • No enabled. The loader decides which queries start: skipToken in place of an entry starts nothing, and the component renders that query's reader only when it has a handle. Data only the browser has gets its handle in the browser, after hydration.
  • Mutations aren't a hook. Actions, useOptimistic and useActionState already give you pending, optimistic and error state. The action awaits the write, then a verb that invalidates what it changed.
  • Hydration is a stream. Handles carry only a key and an input. The server's cache streams to the browser as queries start and settle, and pending queries cross as promises.

What we leave out

TanStack Query is mature and broad. raf-query is a narrower experiment: what that cache looks like when queries start in loaders and server components, and components only read them. The gaps below are intentional, thinned, or outside that scope.

Intentional

Things that would undo the render-as-you-fetch shape:

TanStack QueryWhy raf-query leaves it outClosest substitute
useQuery that starts a request in renderStarting in render is what causes request waterfallsload in a loader or server component · waterfallHandle escape hatch
enabled waiting for inputThe route owns the input; if it isn't there yet, don't start the queryURL / loaderDeps gate the start · skipToken skips an entry · browser-only queries
refetchOnMount / retryOnMountMounting a reader must never fetch; whoever started the query decidesLoader rerun · invalidate · focus / reconnect · refetch()
Changing input at the read siteAnything that changes what a query fetches lives in the URL so the loader starts the right oneSearch params → loader → new handle
Dependent queries as a normal patternawait-then-start chains create waterfalls; reshape the route or backend insteadRoute input · backend join · waterfallHandle last resort
Arbitrary placeholderDataOnly the keep-previous-page case is needed for URL-driven list switcheswhilePending: "previous" + isPrevious
useMutation / MutationCache / mutation keys / offline mutationsReact Actions, useOptimistic and useActionState already cover pending, optimistic UI and errors; the cache only invalidates or writes afterA React action that awaits the write, then invalidateMany · setData
Non-suspense status model (isLoading, status, fetchStatus)The API is thenable-first: the thenable carries status; the state stays thin (isPending, isFetching, isStale, error)thenable.status · use(promise) under Suspense
useQueries / useSuspenseQueries / combineParallelism belongs in the loader (start every query before awaiting any), not a multi-observer hookOne load with several entries. Starting queries
Manual HydrationBoundary + dehydrate as the default pathHandles carry key + input only; the cache streams. RSC and React Router still use a boundaryRouter stream · RSC HydrationBoundary
Separate prefetchInfinite* / fetchInfinite* verbsLoaders and verbs tell paged from plain by the factory; only reading needs its own hookinfiniteQueryOptions + useInfiniteQuery

Thinned

Same problem space, fewer knobs:

TanStack QueryWhat raf-query does instead
networkMode (offlineFirst / always) + fetchStatus: "paused"retry() waits while offline before retrying; the first attempt always runs
refetchOnWindowFocus / Reconnect: 'always' or a functionmeta.refetchOnWindowFocus: false opts a query out; otherwise read, stale queries refetch
refetchIntervalInBackgroundmeta.refetchInterval polls while a component reads the query
initialData / initialDataUpdatedAtSeed with setData or hydration, or let the loader fetch
Custom structuralSharing merge functionBoolean on / off; deep equality on plain objects and arrays
Query filter type / stale / fetchStatus / predicateTargets: a factory with a partial input, a group, or { where(query, param) } over key, input, data and meta
cancelQueries as its own verbremoveOne / removeMany abort requests; a queryFn gets the signal
notifyOnChangePropsAlways tracked: a reader re-renders when its thenable or a state field it read changes
throwOnError as a functionuse() throws a first load's error; read the thenable's status to handle it in place, and the state's error for a background refetch's

Out of scope

TanStack QueryWhy
Multi-framework adapters (Vue, Solid, Svelte, Angular, …)A React + Suspense / RSC / loader experiment, not a framework-agnostic product
PersistQueryClient / storage persisters / per-query persisterNo offline persistence story; the cache is in-memory plus the SSR stream
Default queryFn / setQueryDefaults / getQueryDefaultsEvery query is an explicit factory; no key → defaultFn registry
Custom queryKeyHashFn / precomputed queryHashOne hashing function; keys stay plain arrays
@tanstack/eslint-plugin-queryFactories and handles carry the constraint instead of lint rules
isMutating / mutation filters / dehydrating mutationsNo mutation subsystem

Why a different API?

TanStack Query is excellent, and it runs anywhere a component renders. That flexibility is also what makes waterfalls easy: a component deep in the tree can only start its query once it renders, which is after its parents have loaded. raf-query trades that flexibility for a shape where the route knows every query up front. The cache underneath is the same idea; raf-query just won't let a render start a request unless you build its handle with the function named waterfallHandle.