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, retries with backoff, structural sharing, infinite pages, 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, and components only read them.

API map

TanStack Queryraf-queryNotes
queryOptions({ queryKey, queryFn })function q(input) { return queryOptions({ ... }) }A factory of one input, so handles can carry the input
queryClient.prefetchQuery() (not awaited)qc.prefetch(factory, input) → handleStarting queries
queryClient.ensureQueryData({ revalidateIfStale })qc.ensure(factory, input) → Promise<QueryHandleWithData>
queryClient.fetchQuery()qc.fetch(factory, input) → Promise<QueryHandleWithData>
useSuspenseQuery()use(useQuery(factory, handle)[0])The hook returns a promise; read it where you need it
useQuery() in render, starting the requestuseWaterfall(factory, input) + useQueryAn escape hatch
useQuery().data (doesn't suspend)promise.status === "fulfilled" ? promise.value : undefinedReading without Suspense
enabled: typeof window !== "undefined"Same, in queryOptionsBrowser-only queries
enabled: falseSame, in queryOptionsReaders suspend until it's enabled; refetch() doesn't run it either. Disabling a query
useInfiniteQuery()useQuery() with an infiniteQueryOptions factoryInfinite queries
placeholderData: keepPreviousDatauseQuery(f, h, { whilePending: "previous" })isPrevious instead of isPlaceholderData
useMutation()Form Actions + qc.invalidate()React provides pending and optimistic state
queryClient.invalidateQueries(filters)qc.invalidate(filter) or qc.invalidate(factory, input)Resolves when read queries have refetched
queryClient.resetQueries / removeQueriesqc.reset(...) / qc.remove(...)Same filters
queryClient.setQueryData / getQueryDataqc.setData(factory, input, updater) / qc.getData(...)
useIsFetching()useIsFetching()Kept for compatibility; prefer router pending + per-query isFetching. Background indicators
HydrationBoundary + dehydrate()Automatic streamingSSR and hydration
cancelQueries()qc.remove(...)Unread background refetches are aborted for you

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 verb by what the route needs:

  • ensure when the route can't render without the data. The loader waits for it, and useQuery returns the data itself.
  • prefetch when the UI can render without it. The loader doesn't wait, and useQuery returns a thenable plus controls.
  • enabled: typeof window !== "undefined" for inputs only the browser has, such as localStorage or the Permissions API. The server never runs it or sends it, and the browser does. See Browser-only queries.
  • useWaterfall only as a last resort. It starts the query in render, which is a request waterfall.
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<...>
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 qc: QueryClient<never>qc =
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<never>queryClient;
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
;
// Never waits: the comments load while the page renders. const
const comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
= const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>(factory: (input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>, input: {
    slug: string;
}): QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
prefetch
(
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>
commentsQuery
, { slug: stringslug });
// Waits only if the post isn't in the cache yet. const
const post: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
= await const qc: QueryClient<never>qc.
QueryClient<never>.ensure<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>(factory: (input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>, input: {
    slug: string;
}): Promise<QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>> (+1 overload)
ensure
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
, { slug: stringslug });
return {
comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
,
post: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
};
}, UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function PostPage(): React.JSX.ElementPostPage, });

Reading without Suspense

The thenable useQuery returns for a prefetch handle is TanStack Query's non-suspending data, with no use(). Read its status:

const [const promise: Thenable<PermissionState>promise, 
const controls: QueryControls<(input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>>
controls
] =
useQuery<(input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>, undefined>(factory: (input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>, handle: QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>>, options?: (Options & ... 1 more ... & {
    ...;
}) | undefined): readonly [promise: ...] (+1 overload)
useQuery
(
function permissionQuery(input: {
    name: PermissionName;
}): QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>
permissionQuery
,
const loaderData: {
    notifications: QueryHandle<typeof permissionQuery>;
}
loaderData
.
notifications: QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>>
notifications
);
const data = const promise: Thenable<PermissionState>promise.status: "pending" | "rejected" | "fulfilled"status === "fulfilled" ? const promise: Promise<PermissionState> & FulfilledStatus<PermissionState>promise.FulfilledStatus<PermissionState>.value: PermissionStatevalue : var undefinedundefined;
const data: PermissionState | undefined
const const error: unknownerror = const promise: Thenable<PermissionState>promise.status: "pending" | "rejected" | "fulfilled"status === "rejected" ? const promise: Promise<PermissionState> & RejectedStatuspromise.RejectedStatus.reason: unknownreason : var undefinedundefined; const { const isFetching: booleanisFetching, const isPending: booleanisPending } =
const controls: QueryControls<(input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>>
controls
;
  • promise.value is the data once status is "fulfilled". promise.reason is the error once it's "rejected".
  • controls.isPending and controls.isFetching are the loading state.
  • The component re-renders when the first load lands.
  • Reading status never throws to an error boundary, which suits optional UI.
  • Call use(promise) 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 | undefineddata } = useQuery<PermissionState, Error, PermissionState, string[]>(options: UndefinedInitialDataOptions<PermissionState, Error, PermissionState, string[]>, queryClient?: QueryClient): UseQueryResult<PermissionState, Error> (+2 overloads)useQuery({
		queryKey: string[]queryKey: ["permission", "notifications"],
		queryFn?: unique symbol | QueryFunction<PermissionState, string[], never> | undefinedqueryFn: async () => {
			const const status: PermissionStatusstatus = await var navigator: Navigatornavigator.Navigator.permissions: Permissionspermissions.Permissions.query(permissionDesc: PermissionDescriptor): Promise<PermissionStatus>query({
				PermissionDescriptor.name: PermissionNamename: "notifications",
			});
			return const status: PermissionStatusstatus.PermissionStatus.state: PermissionStatestate;
		},
		// Only the browser has `navigator.permissions`.
		enabled?: QueryBooleanOption<PermissionState, Error, PermissionState, string[]> | undefinedenabled: typeof var window: Window & typeof globalThiswindow !== "undefined",
	});
	return <
function NotificationsToggle(props: {
    readonly permission: PermissionState | undefined;
}): React.ReactNode
NotificationsToggle
permission: PermissionState | undefinedpermission={const data: PermissionState | undefineddata} />;
}

With raf-query, the factory says the query runs in the browser only, the loader starts it, and the component reads its status:

export function 
function permissionQuery(input: {
    name: PermissionName;
}): QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>
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<PermissionState, unknown, never, readonly ["permission", PermissionName], undefined>(options: Omit<QueryOptions<{
    data: PermissionState;
    error: unknown;
    key: readonly ["permission", PermissionName];
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>
queryOptions
({
queryKey: readonly ["permission", PermissionName]queryKey: ["permission",
input: {
    name: PermissionName;
}
input
.name: PermissionNamename],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
    data: PermissionState;
    error: unknown;
    key: readonly ["permission", PermissionName];
    services: never;
}>
queryFn
: async () => {
const const status: PermissionStatusstatus = await var navigator: Navigatornavigator.Navigator.permissions: Permissionspermissions.Permissions.query(permissionDesc: PermissionDescriptor): Promise<PermissionStatus>query(
input: {
    name: PermissionName;
}
input
);
return const status: PermissionStatusstatus.PermissionStatus.state: PermissionStatestate; }, // Only the browser has `navigator.permissions`. enabled?: boolean | undefinedenabled: typeof var window: Window & typeof globalThiswindow !== "undefined", }); }
export const 
const Route: Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", "/settings", "/settings", "/settings", undefined, ResolveParams<"/settings">, AnyContext, AnyContext, AnyContext, {}, (args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => {
    ...;
}, ... 4 more ..., undefined>
Route
=
createFileRoute<"/settings", RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", "/settings", "/settings">(path?: "/settings" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
createFileRoute
("/settings")({
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, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => {
    ...;
}, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader
: (
args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", ResolveParams<"/settings">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
args
) => ({
notifications: QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>>
notifications
:
args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", ResolveParams<"/settings">, {}, AnyContext, AnyContext, AnyContext, unknown, 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: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>>(factory: (input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>, input: {
    name: PermissionName;
}): QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>>
prefetch
(
function permissionQuery(input: {
    name: PermissionName;
}): QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>
permissionQuery
, {
name: PermissionNamename: "notifications", }), }), UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function NotificationsSetting(): React.JSX.ElementNotificationsSetting, });
function function NotificationsSetting(): React.JSX.ElementNotificationsSetting() {
	const [const promise: Thenable<PermissionState>promise] = 
useQuery<(input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>, undefined>(factory: (input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>, handle: QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>>, options?: (Options & ... 1 more ... & {
    ...;
}) | undefined): readonly [promise: ...] (+1 overload)
useQuery
(
function permissionQuery(input: {
    name: PermissionName;
}): QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>
permissionQuery
,
const loaderData: {
    notifications: QueryHandle<typeof permissionQuery>;
}
loaderData
.
notifications: QueryHandle<(input: {
    name: PermissionName;
}) => QueryOptions<Flat<{
    data: PermissionState;
    key: readonly ["permission", PermissionName];
}>>>
notifications
);
const const permission: PermissionState | undefinedpermission = const promise: Thenable<PermissionState>promise.status: "pending" | "rejected" | "fulfilled"status === "fulfilled" ? const promise: Promise<PermissionState> & FulfilledStatus<PermissionState>promise.FulfilledStatus<PermissionState>.value: PermissionStatevalue : var undefinedundefined; return <
function NotificationsToggle(props: {
    readonly permission: PermissionState | undefined;
}): React.ReactNode
NotificationsToggle
permission: PermissionState | undefinedpermission={const permission: PermissionState | undefinedpermission} />;
}

What stays the same

  • Query keys are arrays, hashed deterministically, and matched by prefix in filters. See Query keys.
  • Defaults: staleTime: 0, gcTime of five minutes, three retries with exponential backoff, refetch on focus and reconnect, structural sharing. See Important defaults.
  • Deduping: every way of starting a query joins a request already in flight for the same key.
  • Infinite queries keep { pages, pageParams }, with getNextPageParam, getPreviousPageParam and maxPages.

What's different

  • 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.
  • 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 select, no placeholderData. Shape data in the queryFn or the component; use whilePending: "previous" to keep showing the last page.
  • enabled doesn't wait for input. Start queries only when the route has their input. enabled: false is for a query that's off for now, such as one behind a flag or only the browser can run.
  • Mutations aren't a hook. Actions, useOptimistic and useActionState already give you pending, optimistic and error state.
  • Hydration is a stream. Handles carry only a key and an input. The server's cache streams to the browser as queries settle, and pending queries cross as promises.
  • Browser-only queries are first-class. enabled: typeof window !== "undefined" skips the server and runs in the browser — push subscriptions, permissions, service workers — with the same factory and handle API. See Browser-only queries.

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 — not unfinished ports of the same API.

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 waterfallsLoader / RSC prefetch · useWaterfall escape hatch
skipToken, enabled waiting for inputThe route owns the input; if it isn't there yet, don't start the queryURL / loaderDeps gate the start · browser-only queries for browser-only inputs
refetchOnMount / retryOnMountMounting a reader must never fetch; whoever started the query decidesLoader revalidate · 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 · useWaterfall last resort
selectKeep one cached shape; derive in the queryFn or the componentqueryFn mapping · component derive
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 setData after the writeForm action · startTransition · qc.invalidate / setData
Non-suspense status model (isLoading, status, fetchStatus)The API is promise-first: the promise carries status; controls stay thin (isPending, isFetching, isStale, error)promise.status · use(promise) under Suspense
useQueries / useSuspenseQueries / combineParallelism belongs in the loader (start every query before awaiting any), not a multi-observer hookMultiple prefetch / ensure · allKeyed
Manual HydrationBoundary + dehydrate as the default pathHandles carry key + input only; the cache streams. RSC still uses a boundary where neededRouter stream · RSC HydrationBoundary
Separate useInfiniteQuery / prefetchInfinite* verbsPaged vs plain is decided only by the factoryinfiniteQueryOptions + useQuery

Thinned

Same problem space, fewer knobs:

TanStack QueryWhat raf-query does instead
networkMode (offlineFirst / always) + fetchStatus: "paused"Retries pause while offline; the first attempt always runs (navigator.onLine is unreliable)
refetchOnWindowFocus / Reconnect: 'always' or a functionBoolean only: refetch read + stale queries, or don't
refetchIntervalInBackgroundPolling is per-read and pauses while the tab is hidden
initialData / initialDataUpdatedAtSeed with setData / hydrate, or let the loader fetch
Custom structuralSharing merge functionBoolean on / off; deep equality on plain objects and arrays
Query filter type / stale / fetchStatus fieldsFilter is { queryKey?, exact?, predicate? }; predicate covers the rest
cancelQueries as its own verbremove cancels; unread background refetches abort when the last reader leaves
Manual notifyOnChangeProps allowlistAutomatic tracked results, like TanStack's default path
throwOnError as a function / for promise queryFnsPromise queryFns always throw; Effect queryFns can return a Result when throwOnError is false

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 (hashKey); 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 call the function named useWaterfall.