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 its state's isFetching, count requests in flight with useIsFetching, and leave navigations to the router's pending UI.

A query's first load suspends, so Suspense fallbacks cover it. Later requests (refetches on focus, invalidation, polling) happen in the background, while the old data stays on screen. Read isFetching on that query's state. 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 state is true while a request for the query is in flight, first loads included:

function 
function Post(props: {
    handle: QueryHandle<typeof postQuery>;
}): React.JSX.Element
Post
(
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 controls: QueryState<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
controls
] =
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
);
const const post: Postpost = use<Post>(usable: React.Usable<Post>): Postuse(
const postThenable: QueryValue<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
postThenable
);
return ( <React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article React.AriaAttributes["aria-busy"]?: Booleanish | undefinedaria-busy={
const controls: QueryState<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
controls
.QueryState<(input: { slug: string; }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>.isFetching: booleanisFetching}>
<React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1> {const post: Postpost.Post.title: stringtitle} {
const controls: QueryState<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
controls
.QueryState<(input: { slug: string; }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>.isFetching: booleanisFetching ? <
function Spinner(props: {
    readonly label: string;
}): 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> ); }

A component that reads isFetching re-renders when a request starts and when it ends; one that doesn't read it doesn't (tracked state). Keep the indicator in a small component of its own, and pass the data down to memo children (or let React Compiler do it): structural sharing keeps the data's identity across a refetch that changed nothing, so they skip re-rendering.

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

useIsFetching() counts the queries with a request in flight, across the whole cache, for one global indicator:

function function GlobalProgress(): React.JSX.Element | nullGlobalProgress() {
	const const fetching: numberfetching = function useIsFetching(target?: AnyFactory | AnyTarget | Group): number

How many queries have a request in flight: all of them, or a factory's, a group's or a target's. For a global loading indicator. Always 0 on the server and while hydrating, so the HTML matches the first browser render.

useIsFetching
();
return const fetching: numberfetching > 0 ? <function ProgressBar(): ReactNodeProgressBar /> : null; }

Pass a target to count only some queries: a factory, a group, or a tag.

const const loadingComments: numberloadingComments = function useIsFetching(target?: AnyFactory | AnyTarget | Group): number

How many queries have a request in flight: all of them, or a factory's, a group's or a target's. For a global loading indicator. Always 0 on the server and while hydrating, so the HTML matches the first browser render.

useIsFetching
(
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery
);

Outside React, isFetching(queryClient, target?) 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;
	}
}