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.
Navigations
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, oruseRouterState({ select: (state) => state.isLoading }) - Next.js:
useLinkStatusfromnext/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): numberHow 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): numberHow 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;
}
}