TanStack Query 03 ⚛️ Query States — Handling loading and error states
A component that calls useQuery receives more than data. It receives a complete description of the request’s lifecycle: whether data exists, whether the request failed, and whether a fetch is currently running. Rendering the correct UI from that description is the difference between an application that feels responsive and one that flickers, shows stale content, or crashes on undefined. The previous chapter established the anatomy of useQuery; this chapter focuses exclusively on the state flags and the render branches they control.
Key point: Check isPending first, then isError, then assume success. This order matches the discriminated union that TypeScript uses to narrow data and error. Checking isError before isPending can produce an error render when the query has not yet started, and accessing data without narrowing produces a type error or a runtime crash.
Why state handling is separate from fetching
The stale-while-revalidate problem.
TanStack Query’s default behavior is to show cached data while refetching in the background. This means a component can be in a success state and a fetching state simultaneously . If the component checks only isFetching and renders a spinner, it replaces visible data with a loading indicator every time the window regains focus. The state flags exist to separate “do we have data?” (status) from “is a request running?” (fetchStatus). The component should render the data when isSuccess is true, and optionally show a subtle indicator when isFetching is also true .
The empty-success problem.
A query that resolves with an empty array has succeeded. The request did not fail, and data exists—it is simply empty. Rendering “Loading” or “Error” for an empty list misrepresents the state of the system. A complete render tree has four branches, not three: pending, error, empty success, and populated success . The fourth branch is easy to forget, and its absence produces a blank screen that looks like a bug.
The v5 loading flag problem.
In v4, isLoading meant “no data yet.” In v5, isLoading was redefined as isPending && isFetching, and isPending became the canonical flag for “no data yet” . A query that is pending but paused—because the network is offline—has isPending: true but isFetching: false, so isLoading is false . A component that checks isLoading for the initial state will render neither a spinner nor data in this scenario. The correct initial-load flag is isPending.
a. The primary state flags
The result object exposes three boolean flags and three corresponding string statuses. Each query occupies exactly one primary state at any moment .
import { useQuery } from '@tanstack/react-query'
function TodoList() {
const { data, isPending, isError, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// isPending: no data yet; the query has not succeeded or failed
if (isPending) {
return <LoadingSkeleton />
}
// isError: the query function threw or returned a rejected promise
if (isError) {
return <ErrorMessage message={error.message} />
}
// At this point, isSuccess is true and data is defined
// TypeScript narrows the type of data after the two checks above
return <TodoItems todos={data} />
}
The error property is typed as Error by default, but it is undefined unless isError is true . The data property is undefined until isSuccess is true. The narrowing works because TanStack Query’s result type is a discriminated union on status, and the boolean flags are derived from that union . Checking the booleans in sequence narrows the type just as checking the string status would.
b. The empty success state
A successful response with no items is not an error and not a loading state. It requires its own render branch .
function FeatureList() {
const { data, isPending, error, isSuccess } = useQuery({
queryKey: ['features'],
queryFn: fetchFeatures,
})
if (isPending) return <LoadingSkeleton />
if (error) return <ErrorMessage message={error.message} />
// isSuccess is true, but the array may be empty
if (isSuccess && data.length === 0) {
return <EmptyState message="No features configured yet." />
}
return <FeatureGrid features={data} />
}
The check isSuccess && data.length === 0 is explicit about the condition. Checking !data.length alone would also match undefined, but after the isPending and error checks, data is guaranteed to be defined. The explicit form documents the intent and protects against future refactoring that removes the earlier checks.
c. Background refetch indicators
The isFetching flag is true whenever a request is running, including background refetches that occur while data is already displayed . It should never gate the entire render. It should appear as a secondary indicator inside the success branch.
function TodoList() {
const { data, isPending, isError, error, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isPending) return <LoadingSkeleton />
if (isError) return <ErrorMessage message={error.message} />
return (
<div>
{/* Subtle indicator: data is visible, a refetch is running */}
{isFetching && <span className="refetch-indicator">Refreshing…</span>}
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
)
}
This pattern prevents the flicker that occurs when a background refetch replaces rendered data with a spinner. The user sees the existing content, a small indicator that fresh data is arriving, and then the updated content when the request resolves.
Complete Example Session
// ============================================
// PART 1: THE QUERY FUNCTION
// ============================================
interface Todo {
id: number
title: string
completed: boolean
}
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos')
// fetch resolves on 4xx/5xx; must throw to enter isError state
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
// ============================================
// PART 2: THE COMPONENT WITH ALL STATE FLAGS
// ============================================
import { useQuery } from '@tanstack/react-query'
function TodoList() {
const {
data, // Todo[] | undefined
isPending, // true when status === 'pending'
isError, // true when status === 'error'
error, // Error | undefined
isFetching, // true during ANY fetch, including background
} = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// ============================================
// PART 3: THE PENDING BRANCH
// ============================================
// No data yet; the first request has not resolved
if (isPending) {
return <LoadingSkeleton />
}
// ============================================
// PART 4: THE ERROR BRANCH
// ============================================
// The query function threw; error is defined here
// TypeScript narrows error to Error after this check
if (isError) {
return (
<div role="alert">
<p>Failed to load todos.</p>
<pre>{error.message}</pre>
</div>
)
}
// ============================================
// PART 5: THE EMPTY SUCCESS BRANCH
// ============================================
// The request succeeded, but there are no items
// This is not a loading or error state
if (data.length === 0) {
return <EmptyState message="No todos yet. Create one to get started." />
}
// ============================================
// PART 6: THE POPULATED SUCCESS BRANCH
// ============================================
// data is defined and non-empty
// isFetching may still be true during a background refetch
return (
<div>
{isFetching && <span className="refetch-badge">Updating…</span>}
<ul>
{data.map((todo) => (
<li key={todo.id}>
{todo.title} {todo.completed ? '✓' : ''}
</li>
))}
</ul>
</div>
)
}
The six parts demonstrate the complete render tree: pending, error, empty success, and populated success. The isFetching flag appears only as a secondary indicator within the populated branch, never as a replacement for the data.
Quick Reference
State Flags and Their Meaning
| Flag | True When | Use For |
|---|---|---|
isPending | No data yet (status === 'pending') | Initial loading skeleton |
isError | Query function threw (status === 'error') | Error message and retry |
isSuccess | Data available (status === 'success') | Normal render (with empty check) |
isFetching | Any fetch running, including background | Subtle refresh indicator |
error | Defined only when isError | Error details |
data | Defined only when isSuccess | Render content |
Render Branch Order
| Order | Check | Render |
|---|---|---|
| 1 | isPending | Loading skeleton |
| 2 | isError | Error message |
| 3 | data.length === 0 | Empty state |
| 4 | default | Populated list |
v5 Migration Notes
| v4 Flag | v5 Equivalent | Note |
|---|---|---|
isLoading | isPending | isLoading now means pending AND fetching |
status === 'loading' | status === 'pending' | String value changed |
Best Practices
✅ Do This:
// Check isPending first, then isError
if (isPending) return <Spinner /> // ✅
if (isError) return <Error message={error.message} /> // ✅
// Handle empty success explicitly
if (data.length === 0) return <EmptyState /> // ✅
// Use isFetching for background indicators only
{isFetching && <span>Refreshing…</span>} // ✅
// Let TypeScript narrow data after checks
return <List data={data} /> // ✅ data is defined
// Show error details from the error object
<pre>{error.message}</pre> // ✅
❌ Don’t Do This:
// Use isLoading for the initial loading state in v5
if (isLoading) return <Spinner /> // Can be false when pending+paused // ❌
// Access data before checking isPending
return <List data={data} /> // Crashes when data is undefined // ❌
// Treat empty success as loading or error
if (data.length === 0) return <Spinner /> // Wrong state // ❌
// Replace data with a spinner during background refetch
if (isFetching) return <Spinner /> // Flickers on every focus // ❌
// Check isError before isPending
if (isError) return <Error /> // Renders during initial load? // ❌ (order matters)
// Assume error is always defined when isError is true
if (error) return <Error /> // error is undefined outside isError // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Spinner flickers on refetch | isFetching gates the entire render | Use isPending for initial; isFetching for indicator |
| Crash on undefined data | data accessed before isPending check | Check isPending and isError first |
| Empty list shows as loading | No explicit empty success branch | Check data.length === 0 after success |
v5 isLoading false when expected | isLoading requires both pending and fetching | Use isPending for “no data yet” |
| Error message blank | error accessed without isError check | Check isError before reading error |
Real-World Examples
1. Pending skeleton
if (isPending) return <Skeleton rows={5} />
2. Error with retry button
if (isError) return <Error message={error.message} onRetry={refetch} />
3. Empty state
if (isSuccess && data.length === 0) return <EmptyState />
4. Background refresh badge
{isFetching && <Badge>Syncing…</Badge>}
5. Type narrowing with isSuccess
if (isSuccess) { data.map(...) } // data is typed
6. Error boundary alternative
if (isError) throw error // Pass to an ErrorBoundary
7. Paused query detection
const { isPending, fetchStatus } = useQuery({...})
if (isPending && fetchStatus === 'paused') return <OfflineNotice />
8. Custom error type narrowing
if (axios.isAxiosError(error)) return <ApiError status={error.response?.status} />
9. Empty state with action
if (data.length === 0) return <EmptyState action={<CreateButton />} />
10. Combined pending and fetching check
// isPending alone is sufficient for initial load
// isFetching inside success branch for refresh
Visual
The Four Render Branches
┌──────────────────────────────────────────────────────────────┐
│ COMPLETE RENDER TREE FOR useQuery │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ isPending? │ │
│ │ YES → Loading skeleton │ │
│ └──────────────────────┬──────────────────────────────────┘ │
│ │ NO │
│ ┌──────────────────────▼──────────────────────────────────┐ │
│ │ isError? │ │
│ │ YES → Error message (error is defined) │ │
│ └──────────────────────┬──────────────────────────────────┘ │
│ │ NO │
│ ┌──────────────────────▼──────────────────────────────────┐ │
│ │ data.length === 0? │ │
│ │ YES → Empty state │ │
│ └──────────────────────┬──────────────────────────────────┘ │
│ │ NO │
│ ┌──────────────────────▼──────────────────────────────────┐ │
│ │ Default: Render data │ │
│ │ Optional: isFetching indicator │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Three branches produce no data. One produces data. │
│ The empty branch is distinct from loading and error. │
└──────────────────────────────────────────────────────────────┘
status vs fetchStatus
┌──────────────────────────────────────────────────────────────┐
│ TWO DIMENSIONS OF QUERY STATE │
│ │
│ status (about DATA): │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ pending │ │ error │ │ success │ │
│ │ no data │ │ threw │ │ has data │ │
│ └──────────┘ └──────────┘ └────┬─────┘ │
│ │ │
│ fetchStatus (about QUERYFN): │ │
│ ┌──────────┐ ┌──────────┐ ┌────▼─────┐ │
│ │ fetching │ │ paused │ │ idle │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ success + fetching = background refetch → render data │
│ pending + paused = offline, no data → isPending true │
│ │
│ Rule: status decides the render branch. │
│ fetchStatus decides the indicator. │
└──────────────────────────────────────────────────────────────┘
isPending vs isLoading in v5
┌──────────────────────────────────────────────────────────────┐
│ WHY isPending IS THE CORRECT INITIAL FLAG │
│ │
│ isPending = status === 'pending' │
│ isFetching = fetchStatus === 'fetching' │
│ isLoading = isPending && isFetching (v5 definition) │
│ │
│ Scenario: component mounts, network is offline │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ status: 'pending' (no data) │ │
│ │ fetchStatus: 'paused' (cannot fetch) │ │
│ │ isPending: true ← use this │ │
│ │ isFetching: false │ │
│ │ isLoading: false ← do NOT use this │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ A component checking isLoading would render nothing. │
│ A component checking isPending renders the skeleton. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Primary states | isPending, isError, isSuccess |
| Initial loading flag | isPending (not isLoading in v5) |
| Background indicator | isFetching |
| Error details | error (defined only when isError) |
| Data access | data (defined only when isSuccess) |
| Empty success | data.length === 0 after success check |
| Render order | isPending → isError → empty → populated |
| v4 to v5 change | isLoading → isPending for initial state |
Key takeaways:
- Check
isPendingbeforeisErrorand before accessingdata. The order matches TypeScript’s discriminated union and prevents undefined access . isLoadingis not the initial loading flag in v5. It meansisPending && isFetching. UseisPendingfor “no data yet,” especially when the network may be offline .- Empty success is a distinct state. A query that resolves with an empty array has succeeded. Rendering a spinner or error for it misrepresents the system .
isFetchingis a secondary indicator, not a render gate. Background refetches should not replace visible data with a spinner.erroris undefined outside the error branch. Readingerror.messagewithout checkingisErrorproduces a crash or a type error .- The four-branch tree is the complete model. Pending, error, empty success, populated success. A three-branch tree that merges empty into error or loading will produce misleading UI.
- TypeScript narrows
dataanderrorafter the checks. OnceisPendingandisErrorare false,datais defined. OnceisErroris true,erroris defined. Trust the narrowing.
Remember: Query states are not a single loading boolean. They are a description of what the cache contains and what the network is doing. Render the pending skeleton when there is no data. Render the error when the request failed. Render the empty state when the request succeeded with nothing. Render the data otherwise. The isFetching flag belongs inside the data branch, not around it. Get the branch order right, and the component handles every state the query can produce.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!