| | |

TanStack Query 03 ⚛️ Query States — Handling loading and error states

⏱️ Estimated read: 11 min | |

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

FlagTrue WhenUse For
isPendingNo data yet (status === 'pending')Initial loading skeleton
isErrorQuery function threw (status === 'error')Error message and retry
isSuccessData available (status === 'success')Normal render (with empty check)
isFetchingAny fetch running, including backgroundSubtle refresh indicator
errorDefined only when isErrorError details
dataDefined only when isSuccessRender content

Render Branch Order

OrderCheckRender
1isPendingLoading skeleton
2isErrorError message
3data.length === 0Empty state
4defaultPopulated list

v5 Migration Notes

v4 Flagv5 EquivalentNote
isLoadingisPendingisLoading 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

PitfallWhy It HappensFix
Spinner flickers on refetchisFetching gates the entire renderUse isPending for initial; isFetching for indicator
Crash on undefined datadata accessed before isPending checkCheck isPending and isError first
Empty list shows as loadingNo explicit empty success branchCheck data.length === 0 after success
v5 isLoading false when expectedisLoading requires both pending and fetchingUse isPending for “no data yet”
Error message blankerror accessed without isError checkCheck 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

ItemValue
Primary statesisPending, isError, isSuccess
Initial loading flagisPending (not isLoading in v5)
Background indicatorisFetching
Error detailserror (defined only when isError)
Data accessdata (defined only when isSuccess)
Empty successdata.length === 0 after success check
Render orderisPending → isError → empty → populated
v4 to v5 changeisLoading → isPending for initial state

Key takeaways:

  • Check isPending before isError and before accessing data. The order matches TypeScript’s discriminated union and prevents undefined access .
  • isLoading is not the initial loading flag in v5. It means isPending && isFetching. Use isPending for “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 .
  • isFetching is a secondary indicator, not a render gate. Background refetches should not replace visible data with a spinner.
  • error is undefined outside the error branch. Reading error.message without checking isError produces 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 data and error after the checks. Once isPending and isError are false, data is defined. Once isError is true, error is 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.


Was this helpful?


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!