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 Query | raf-query | Notes |
|---|---|---|
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) → handle | Starting 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 request | useWaterfall(factory, input) + useQuery | An escape hatch |
useQuery().data (doesn't suspend) | promise.status === "fulfilled" ? promise.value : undefined | Reading without Suspense |
enabled: typeof window !== "undefined" | Same, in queryOptions | Browser-only queries |
enabled: false | Same, in queryOptions | Readers suspend until it's enabled; refetch() doesn't run it either. Disabling a query |
useInfiniteQuery() | useQuery() with an infiniteQueryOptions factory | Infinite queries |
placeholderData: keepPreviousData | useQuery(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 / removeQueries | qc.reset(...) / qc.remove(...) | Same filters |
queryClient.setQueryData / getQueryData | qc.setData(factory, input, updater) / qc.getData(...) | |
useIsFetching() | useIsFetching() | Kept for compatibility; prefer router pending + per-query isFetching. Background indicators |
HydrationBoundary + dehydrate() | Automatic streaming | SSR 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:
ensurewhen the route can't render without the data. The loader waits for it, anduseQueryreturns the data itself.prefetchwhen the UI can render without it. The loader doesn't wait, anduseQueryreturns a thenable plus controls.enabled: typeof window !== "undefined"for inputs only the browser has, such aslocalStorageor the Permissions API. The server never runs it or sends it, and the browser does. See Browser-only queries.useWaterfallonly as a last resort. It starts the query in render, which is a request waterfall.
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 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.valueis the data oncestatusis"fulfilled".promise.reasonis the error once it's"rejected".controls.isPendingandcontrols.isFetchingare the loading state.- The component re-renders when the first load lands.
- Reading
statusnever 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,gcTimeof 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 }, withgetNextPageParam,getPreviousPageParamandmaxPages.
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.
undefinedis a valid result. AqueryFntypedPromise<User | undefined>cachesundefinedlike any other value. TanStack Query treats it as an error.- No
select, noplaceholderData. Shape data in thequeryFnor the component; usewhilePending: "previous"to keep showing the last page. enableddoesn't wait for input. Start queries only when the route has their input.enabled: falseis 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,
useOptimisticanduseActionStatealready 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 Query | Why raf-query leaves it out | Closest substitute |
|---|---|---|
useQuery that starts a request in render | Starting in render is what causes request waterfalls | Loader / RSC prefetch · useWaterfall escape hatch |
skipToken, 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 · browser-only queries for browser-only inputs |
refetchOnMount / retryOnMount | Mounting a reader must never fetch; whoever started the query decides | Loader revalidate · 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 · useWaterfall last resort |
select | Keep one cached shape; derive in the queryFn or the component | queryFn mapping · component derive |
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 setData after the write | Form 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 / combine | Parallelism belongs in the loader (start every query before awaiting any), not a multi-observer hook | Multiple prefetch / ensure · allKeyed |
Manual HydrationBoundary + dehydrate as the default path | Handles carry key + input only; the cache streams. RSC still uses a boundary where needed | Router stream · RSC HydrationBoundary |
Separate useInfiniteQuery / prefetchInfinite* verbs | Paged vs plain is decided only by the factory | infiniteQueryOptions + useQuery |
Thinned
Same problem space, fewer knobs:
| TanStack Query | What 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 function | Boolean only: refetch read + stale queries, or don't |
refetchIntervalInBackground | Polling is per-read and pauses while the tab is hidden |
initialData / initialDataUpdatedAt | Seed with setData / hydrate, or let the loader fetch |
Custom structuralSharing merge function | Boolean on / off; deep equality on plain objects and arrays |
Query filter type / stale / fetchStatus fields | Filter is { queryKey?, exact?, predicate? }; predicate covers the rest |
cancelQueries as its own verb | remove cancels; unread background refetches abort when the last reader leaves |
Manual notifyOnChangeProps allowlist | Automatic tracked results, like TanStack's default path |
throwOnError as a function / for promise queryFns | Promise queryFns always throw; Effect queryFns can return a Result when throwOnError is false |
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 (hashKey); 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 call the function named useWaterfall.