Skip to main content

TanStack Query: Handle Loading and Errors Like a Pro

Loading and error states are where most UI bugs hide. TanStack Query returns multiple state flags—isLoading, isFetching, isError, isPending—each capturing a different phase of the query lifecycle. Using the wrong flag can cause flashing skeletons during background refetches, showing error screens briefly before a retry succeeds, or missing errors entirely. This article teaches the state machine behind these flags and patterns for building robust, user-friendly error UIs that never confuse users.

I once shipped a feature where a failed mutation showed a red error banner for 200 milliseconds while the automatic retry succeeded silently, giving users whiplash. Learning to distinguish between isError (did the last request fail?) and the retry logic changed how I build error UIs. Now I show errors only when all retries are exhausted, and users see a stable, actionable screen.

The Query State Machine

TanStack Query manages queries through a state machine with six possible states:

idle → loading → success → stale → fetching → success

error (may retry) → loading → success

The key insight: a single query can be "loading" on initial fetch, "error" temporarily, then "success" after a retry. Here are the flags:

FlagMeaning
status: 'pending'Query has never completed (initial load)
status: 'success'Last fetch succeeded; data is populated
status: 'error'Last fetch failed; error is populated
isLoadingtrue if status === 'pending' (first-time load)
isFetchingtrue if any fetch is in flight (initial, refetch, retry)
isErrortrue if status === 'error'
isStaletrue if cached data exceeded staleTime
isPendingtrue if no data has ever been returned (status !== 'success')

Here is the progression:

  1. Initial mount: status: 'pending', isLoading: true, isFetching: true, data: undefined
  2. Fetch completes: status: 'success', isLoading: false, isFetching: false, data: {...}
  3. Data becomes stale: status: 'success', isStale: true (data remains in state)
  4. Background refetch triggered: isFetching: true, data: {...} (old data displayed)
  5. Refetch completes: isFetching: false, data: {...} (updated data)

If a fetch fails:

  1. Fetch fails: status: 'error', isError: true, isFetching: false, error: {...}
  2. Retry triggered: isFetching: true, error: {...} (old error stays until retry succeeds)
  3. Retry succeeds: status: 'success', isError: false, data: {...}

Building Loading UI with isLoading vs isFetching

Use isLoading (not isFetching) for skeleton screens:

function UserProfile({ userId }) {
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
},
});

if (isLoading) {
return (
<div>
<Skeleton width={200} height={20} />
<Skeleton width={300} height={40} />
</div>
);
}

return <div>{user.name}</div>;
}

Using isLoading (instead of isFetching) prevents skeleton screens from flashing during background refetches. If the component stays mounted and data is refetched, isFetching becomes true but isLoading stays false, so the skeleton is not re-shown.

For subtle "refreshing" indicators during background fetches, use isFetching && !isLoading:

function UserProfile({ userId }) {
const { data: user, isLoading, isFetching } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
staleTime: 1000 * 60, // 1 minute
});

if (isLoading) return <UserSkeleton />;

return (
<div>
<h1>{user.name}</h1>
{isFetching && !isLoading && (
<small style={{ color: 'gray' }}>Updating...</small>
)}
</div>
);
}

Error Handling Patterns

Display errors only when all retries are exhausted:

function UserProfile({ userId }) {
const { data: user, isError, error } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
retry: 3, // Retry up to 3 times before showing error
});

if (isError) {
return (
<div style={{ color: 'red', padding: '10px', border: '1px solid red' }}>
<p>Failed to load user: {error.message}</p>
<button onClick={() => location.reload()}>Reload Page</button>
</div>
);
}

return <div>{user?.name}</div>;
}

The retry: 3 option retries the fetcher 3 times before marking the query as failed. Users do not see an error screen unless all retries fail. For transient errors (network hiccup), the retry happens silently; only persistent errors are shown.

Customize retry logic to skip retrying for certain error codes:

const { data: user, error, isError } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (res.status === 404) throw new Error('User not found');
if (res.status === 401) throw new Error('Unauthorized');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
retry: (failureCount, error) => {
// Don't retry on 4xx errors (likely permanent)
if (error.message.includes('404') || error.message.includes('401')) {
return false;
}
// Retry up to 3 times on 5xx or network errors
return failureCount < 3;
},
});

Advanced Error States

Store additional error context per query:

function useUserWithError(userId) {
const queryInfo = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) {
// Enrich error with additional context
const error = new Error('Failed to fetch user');
error.status = res.status;
error.statusText = res.statusText;
throw error;
}
return res.json();
},
});

const errorType = queryInfo.error?.status
? queryInfo.error.status < 500 ? 'client' : 'server'
: null;

return { ...queryInfo, errorType };
}

function UserProfile({ userId }) {
const { data: user, isError, error, errorType } = useUserWithError(userId);

if (isError) {
if (errorType === 'client') {
return <div>User not found or access denied.</div>;
} else {
return <div>Server error. Please try again later.</div>;
}
}

return <div>{user.name}</div>;
}

Using useQueryErrorResetBoundary

TanStack Query provides an error boundary integration to reset errors when the user navigates:

import { useQueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div style={{ color: 'red' }}>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}

function App() {
const { reset } = useQueryErrorResetBoundary();

return (
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={reset}>
<UserProfile userId={1} />
</ErrorBoundary>
);
}

This integrates TanStack Query errors with React Error Boundary, catching errors that occur during render and allowing users to retry.

Retry and Exponential Backoff

Use exponential backoff for retries to avoid overwhelming the server:

const { data } = useQuery({
queryKey: ['data'],
queryFn: fetchData,
retry: 3,
retryDelay: (attemptIndex) => {
// 1st retry: 1s, 2nd: 2s, 3rd: 4s
return Math.min(1000 * 2 ** attemptIndex, 30000);
},
});

The retryDelay function returns milliseconds to wait before the next retry. 2 ** attemptIndex creates exponential backoff: 1s, 2s, 4s, etc. Math.min(..., 30000) caps the delay at 30 seconds.

Optimistic Error Handling in Mutations

When mutating data, show optimistic updates but gracefully handle failures:

function useAddPost() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async (newPost) => {
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
});
if (!res.ok) throw new Error('Failed to create post');
return res.json();
},
onMutate: async (newPost) => {
// Optimistically update the cache
await queryClient.cancelQueries({ queryKey: ['posts'] });
const previousPosts = queryClient.getQueryData(['posts']);
queryClient.setQueryData(['posts'], (old) => [
...(old || []),
{ id: Date.now(), ...newPost },
]);
return { previousPosts };
},
onError: (error, newPost, context) => {
// Revert on error
if (context?.previousPosts) {
queryClient.setQueryData(['posts'], context.previousPosts);
}
console.error('Failed:', error.message);
},
onSuccess: () => {
// Refetch to get server-assigned ID
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}

This mutation optimistically adds a post to the cache, then reverts if the server call fails. The user sees instant feedback, but the cache is correct even if the mutation fails.

Key Takeaways

  • Use isLoading for initial-load skeletons; use isFetching && !isLoading for subtle refresh indicators.
  • Set retry: N to automatically retry failed requests; customize with a retryDelay function for exponential backoff.
  • Customize retry logic with a function to skip retries for permanent errors (4xx) while retrying transient failures (5xx, network).
  • Show error UI only after all retries are exhausted, preventing jarring flashes of error screens.
  • Use useQueryErrorResetBoundary to integrate TanStack Query errors with React Error Boundary.
  • Optimize mutations with onMutate for instant feedback and onError to revert optimistic updates on failure.

Frequently Asked Questions

Why does isError stay true after a retry succeeds?

In early TanStack Query versions, this was a common complaint. In v4+, status changes to 'success' and isError becomes false on a successful retry. If you are seeing isError: true after a successful retry, upgrade your TanStack Query version.

Can I set a global default for retries?

Yes, set it on the QueryClient:

const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000) },
},
});

What is the difference between error and errors?

error (singular) is the error from the most recent failed request. If a query fails, retries, and fails again, error is the error from the final attempt. There is no errors array; TanStack Query only tracks the latest error.

Can I show different UI based on error type?

Yes, throw custom Error objects or add properties to errors in the fetcher:

if (res.status === 404) {
const error = new Error('Not found');
error.code = 'NOT_FOUND';
throw error;
}

Then check error.code in your UI.

Should I use Error Boundary or isError for query errors?

Use isError in your component for expected errors (network failures, 404s). Use Error Boundary for unexpected errors (bugs in your code, null reference errors). They work together: Error Boundary catches render-time errors, while isError handles data-fetching errors.

Further Reading