Skip to content

raf-query is early. The API can still change between minor releases, so use it at your own risk.

raf-query

Guides

Background fetching indicators

Show that a query is refreshing with isFetching. Prefer the router's pending UI for navigations; useIsFetching stays for compatibility when you need a global count.

A query's first load suspends, so Suspense fallbacks cover it. Later requests (refetches on focus, invalidation) happen in the background, while the old data stays on screen. Read isFetching on that query's controls. For navigations, prefer the router's pending UI — you usually don't need a global fetch counter.

One query: isFetching

isFetching on the reader's controls is true while any request for the query is in flight, first loads included:

function 
function Post(props: {
    handle: QueryHandleWithData<typeof postQuery>;
}): React.JSX.Element
Post
(
props: {
    handle: QueryHandleWithData<typeof postQuery>;
}
props
: {
handle: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
handle
: interface QueryHandleWithData<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandleWithData<typeof
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
> }) {
const [const post: Postpost,
const postState: QueryControls<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
postState
] =
useQuery<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>, undefined>(factory: (input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>, handle: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>, options?: (Options & {
    readonly throwOnError?: never;
} & {
    readonly throwOnError?: undefined;
}) | undefined): readonly [data: Post, controls: QueryControls<...>] (+1 overload)
useQuery
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
,
props: {
    handle: QueryHandleWithData<typeof postQuery>;
}
props
.
handle: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
handle
);
return ( <React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article React.AriaAttributes["aria-busy"]?: Booleanish | undefinedaria-busy={
const postState: QueryControls<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
postState
.QueryControls<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>.isFetching: booleanisFetching}>
<React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1> {const post: Postpost.Post.title: stringtitle} {
const postState: QueryControls<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
postState
.QueryControls<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>.isFetching: booleanisFetching ? <
function Spinner(props: {
    readonly label: string;
}): React.ReactNode
Spinner
label: stringlabel="Refreshing" /> : null}
</React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1> <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>{const post: Postpost.Post.body: stringbody}</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p> </React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> ); }

Controls are tracked: only components that read isFetching re-render when it changes. A parent that just passes the promise down stays put during background refetches.

A navigation is not a background fetch: it's a transition. Use the router's pending state for the navigation itself:

  • TanStack Start: pendingComponent / pendingMs, or useRouterState({ select: (state) => state.isLoading })
  • Next.js: useLinkStatus from next/link

isFetching covers what happens after the page has rendered.

useIsFetching

You probably don't need this. Prefer the router's pending UI for navigations (TanStack Start, Next.js) and per-query isFetching for background refetches. The hook stays for TanStack Query compatibility when you want one count of every in-flight request across the app:

function function GlobalProgress(): React.JSX.Element | nullGlobalProgress() {
	const const fetching: numberfetching = function useIsFetching(filter?: Filter): numberuseIsFetching();
	return const fetching: numberfetching > 0 ? <function ProgressBar(): React.ReactNodeProgressBar /> : null;
}

Pass a filter to count only some queries:

const const savingComments: numbersavingComments = function useIsFetching(filter?: Filter): numberuseIsFetching({ Filter.queryKey?: QueryKey | undefinedqueryKey: ["comments"] });

It's always 0 on the server and during hydration, so the indicator never appears in server HTML. Outside React, qc.isFetching(filter?) returns the same number.

Refetch the queries below, together or one at a time, and watch both indicators:

Avoiding flashes

On a fast connection, a background refetch can finish in 50 ms, and a spinner that flashes for that long is more distracting than helpful. Delay it with CSS, so it only appears for slower requests:

.spinner {
	animation: fade-in 0s 300ms both;
}

@keyframes fade-in {
	from {
		opacity: 0;
	}
}