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 Query | raf-query | Notes |
|---|---|---|
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) returns | Reading queries |
useQuery() in render, starting the request | useQuery(factory, waterfallHandle(factory, input)) | An escape hatch |
useQuery() refetches stale data on mount (refetchOnMount: true) | Reading never refetches; the loader decides freshness | Stale 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 returns | Reading without Suspense |
enabled: false, skipToken | skipToken in place of a load entry | Its handle is undefined; render the reader only with a handle. Conditional queries |
enabled: typeof window !== "undefined" | waterfallHandle in a component that renders once hydrated | No loader entry. Browser-only queries |
useInfiniteQuery() | useInfiniteQuery(factory, handle) with an infiniteQueryOptions factory | Infinite queries |
placeholderData: keepPreviousData | useQuery(f, h, { whilePending: "previous" }) | isPrevious instead of isPlaceholderData |
throwOnError | Read 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, invalidateAll | By factory, group or where. Resolves when read queries have refetched. Query invalidation |
queryClient.resetQueries / removeQueries | resetOne / resetMany / resetAll, removeOne / removeMany / removeAll | The same targets |
queryClient.setQueryData / getQueryData | setData(qc, factory, input, updater) / getData(qc, factory, input) | |
useIsFetching() | useIsFetching(target?) | Background indicators |
HydrationBoundary + dehydrate() | Automatic streaming with a router integration | SSR and hydration |
cancelQueries() | removeOne(...) / removeMany(...) | Removing an entry aborts its request's signal |
Register with queryMeta | meta typed by the client's links and features | Query 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:
swrwhen the route can't render without the data. The loader waits for it, anduse()returns its data without suspending.prefetchwhen the UI can render without it. The loader doesn't wait, anduse()suspends until the data lands.skipTokenin place of an entry when the route doesn't need the query, such as drafts only an admin sees. Its handle isundefined. See Conditional queries.waterfallHandleonly 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 aslocalStorageor the Permissions API, once the page has hydrated. See Browser-only queries.
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.
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>
);
}
}
statusis"pending","fulfilled"or"rejected".valueis the data once it's"fulfilled", andreasonis the error once it's"rejected".postState.isFetchingis a background refetch, which keepsstatusat"fulfilled".- The component re-renders when the first load lands.
- Reading
statusnever 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 | undefinedThe 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)useQuery({
queryKey: string[]The query key to use for this query.
queryKey: ["permission", "notifications"],
queryFn?: unique symbol | QueryFunction<PermissionState, string[], never> | undefinedThe 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: NavigatorThe 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: PermissionsThe 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: PermissionStateThe 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[]> | undefinedSet 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 globalThisThe 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 | undefinedThe 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: NavigatorThe 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: PermissionsThe 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: PermissionStateThe 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(): booleanReturn 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.
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: 0and agcTimeof 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.
selectderives what readers get from the cached data.- Infinite queries keep
{ pages, pageParams }, withgetNextPageParam,getPreviousPageParamandmaxPages.
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. Awheretarget picks queries bymetaor data, which is how an app writes tags. metais typed by the client. Links and features declare themetafields they read, and factories built withqueryOptionsbound to that client are checked against them.undefinedis a valid result. AqueryFntypedPromise<User | undefined>cachesundefinedlike any other value. TanStack Query treats it as an error.- No
placeholderData. UsewhilePending: "previous"to keep showing the last page. - No
enabled. The loader decides which queries start:skipTokenin 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,
useOptimisticanduseActionStatealready 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 Query | Why raf-query leaves it out | Closest substitute |
|---|---|---|
useQuery that starts a request in render | Starting in render is what causes request waterfalls | load in a loader or server component · waterfallHandle escape hatch |
enabled waiting for input | The route owns the input; if it isn't there yet, don't start the query | URL / loaderDeps gate the start · skipToken skips an entry · browser-only queries |
refetchOnMount / retryOnMount | Mounting a reader must never fetch; whoever started the query decides | Loader rerun · invalidate · focus / reconnect · refetch() |
| Changing input at the read site | Anything that changes what a query fetches lives in the URL so the loader starts the right one | Search params → loader → new handle |
| Dependent queries as a normal pattern | await-then-start chains create waterfalls; reshape the route or backend instead | Route input · backend join · waterfallHandle last resort |
Arbitrary placeholderData | Only the keep-previous-page case is needed for URL-driven list switches | whilePending: "previous" + isPrevious |
useMutation / MutationCache / mutation keys / offline mutations | React Actions, useOptimistic and useActionState already cover pending, optimistic UI and errors; the cache only invalidates or writes after | A 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 / combine | Parallelism belongs in the loader (start every query before awaiting any), not a multi-observer hook | One load with several entries. Starting queries |
Manual HydrationBoundary + dehydrate as the default path | Handles carry key + input only; the cache streams. RSC and React Router still use a boundary | Router stream · RSC HydrationBoundary |
Separate prefetchInfinite* / fetchInfinite* verbs | Loaders and verbs tell paged from plain by the factory; only reading needs its own hook | infiniteQueryOptions + useInfiniteQuery |
Thinned
Same problem space, fewer knobs:
| TanStack Query | What 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 function | meta.refetchOnWindowFocus: false opts a query out; otherwise read, stale queries refetch |
refetchIntervalInBackground | meta.refetchInterval polls while a component reads the query |
initialData / initialDataUpdatedAt | Seed with setData or hydration, or let the loader fetch |
Custom structuralSharing merge function | Boolean on / off; deep equality on plain objects and arrays |
Query filter type / stale / fetchStatus / predicate | Targets: a factory with a partial input, a group, or { where(query, param) } over key, input, data and meta |
cancelQueries as its own verb | removeOne / removeMany abort requests; a queryFn gets the signal |
notifyOnChangeProps | Always tracked: a reader re-renders when its thenable or a state field it read changes |
throwOnError as a function | use() 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 Query | Why |
|---|---|
| Multi-framework adapters (Vue, Solid, Svelte, Angular, …) | A React + Suspense / RSC / loader experiment, not a framework-agnostic product |
PersistQueryClient / storage persisters / per-query persister | No offline persistence story; the cache is in-memory plus the SSR stream |
Default queryFn / setQueryDefaults / getQueryDefaults | Every query is an explicit factory; no key → defaultFn registry |
Custom queryKeyHashFn / precomputed queryHash | One hashing function; keys stay plain arrays |
@tanstack/eslint-plugin-query | Factories and handles carry the constraint instead of lint rules |
isMutating / mutation filters / dehydrating mutations | No 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.