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:
| Flag | Meaning |
|---|---|
status: 'pending' | Query has never completed (initial load) |
status: 'success' | Last fetch succeeded; data is populated |
status: 'error' | Last fetch failed; error is populated |
isLoading | true if status === 'pending' (first-time load) |
isFetching | true if any fetch is in flight (initial, refetch, retry) |
isError | true if status === 'error' |
isStale | true if cached data exceeded staleTime |
isPending | true if no data has ever been returned (status !== 'success') |
Here is the progression:
- Initial mount:
status: 'pending',isLoading: true,isFetching: true,data: undefined - Fetch completes:
status: 'success',isLoading: false,isFetching: false,data: {...} - Data becomes stale:
status: 'success',isStale: true(data remains in state) - Background refetch triggered:
isFetching: true,data: {...}(old data displayed) - Refetch completes:
isFetching: false,data: {...}(updated data)
If a fetch fails:
- Fetch fails:
status: 'error',isError: true,isFetching: false,error: {...} - Retry triggered:
isFetching: true,error: {...}(old error stays until retry succeeds) - 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
isLoadingfor initial-load skeletons; useisFetching && !isLoadingfor subtle refresh indicators. - Set
retry: Nto automatically retry failed requests; customize with aretryDelayfunction for exponential backoff. - Customize
retrylogic 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
useQueryErrorResetBoundaryto integrate TanStack Query errors with React Error Boundary. - Optimize mutations with
onMutatefor instant feedback andonErrorto 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.