DEV Community

S M Tahosin
S M Tahosin

Posted on

TanStack Query v5: Why status === 'pending' Broke Your Loading States (and the 3 Patterns That Fix It)

You upgrade TanStack Query to v5. Your app builds. Tests pass. You open the dashboard and every "loading spinner" component is stuck in a permanent loading state — or worse, flashes through loading → empty → data so fast it looks broken.

Welcome to the status === 'loading'status === 'pending' rename, which on the surface is a one-line find/replace but in practice subtly changes what the status field means. If, like me, you had components doing switch (status) or passing status as a prop to downstream components, the rename isn't enough — the semantics underneath shifted too.

I ran into this in a real project and then again helping someone in TanStack/query#10255. Writing it up because the v5 migration guide does mention this change, but it's two bullets that don't fully convey what you'll need to refactor.

What actually changed

In v4, the status field was a tristate: 'loading' | 'success' | 'error'. It conflated two ideas:

  1. Do I have data? — yes/no
  2. Am I actively fetching? — yes/no

For most queries those two tracked together (loading meant "no data and fetching"), which is why a single field worked. But they diverged in edge cases: what do you call a query with enabled: false that has no data but isn't fetching? v4 called that 'loading' too, which was a lie — nothing was loading.

v5 split the two:

  • status is now 'pending' | 'success' | 'error'. It strictly answers "do I have data or an error?" — never lies about whether data exists.
  • fetchStatus is 'fetching' | 'idle' | 'paused'. Orthogonal to status. Answers "is a network request in flight?"

So the thing v4's 'loading' really meant ("no data AND currently fetching") is now a combination of two fields:

// v4
status === 'loading'

// v5 equivalent
status === 'pending' && fetchStatus === 'fetching'
Enter fullscreen mode Exit fullscreen mode

Which, conveniently, is also the definition of the new v5 flag isLoading. TanStack Query v5 ships both the new boolean and keeps the two-axis design, so you have choices about which API you read against.

The complete v4 → v5 mapping

Here's the full table I keep bookmarked when I'm doing a migration:

v4 v5 status + fetchStatus v5 boolean flag
status === 'loading' (no data, actively fetching) status === 'pending' && fetchStatus === 'fetching' isLoading
status === 'loading' (no data, paused/disabled) status === 'pending' && fetchStatus !== 'fetching' isPending && !isFetching
isFetching (have data, refreshing in background) status === 'success' && fetchStatus === 'fetching' isFetching && !isPending
status === 'success' status === 'success' isSuccess
status === 'error' status === 'error' isError

Two things worth noting:

  • The v5 boolean isLoading is exactly status === 'pending' && fetchStatus === 'fetching', derived internally. So if your codebase already read isLoading instead of status === 'loading', v5 is a seamless upgrade — the flag preserves the old meaning.
  • The "no data but not fetching" case (which v4 lied about) is what breaks most apps. It happens whenever you have enabled: false gated queries, or queries waiting on a dependency — your loading UI shows forever because isPending === true but nothing is actually going to load until the gate opens.

Three patterns that work

Pattern 1: just use the boolean flags (recommended if you're not too deep)

The simplest migration: stop reading status in your components, use the derived booleans. They're stable across versions and cover every combination:

// Before (v4)
function Dashboard() {
  const { status, data } = useQuery({ queryKey: ['stats'], queryFn: fetchStats });
  if (status === 'loading') return <Spinner />;
  if (status === 'error')   return <ErrorBanner />;
  return <Stats data={data} />;
}

// After (v5) — zero semantic change
function Dashboard() {
  const { isLoading, isError, data } = useQuery({
    queryKey: ['stats'],
    queryFn: fetchStats,
  });
  if (isLoading) return <Spinner />;
  if (isError)   return <ErrorBanner />;
  return <Stats data={data} />;
}
Enter fullscreen mode Exit fullscreen mode

If you have a <QueryState> wrapper component that took status as a prop, change it to take isLoading / isError / isSuccess instead:

type QueryStateProps<T> = {
  isLoading: boolean;
  isError: boolean;
  data: T | undefined;
  children: (data: T) => React.ReactNode;
};

function QueryState<T>({ isLoading, isError, data, children }: QueryStateProps<T>) {
  if (isLoading) return <Spinner />;
  if (isError)   return <ErrorBanner />;
  if (data === undefined) return <EmptyState />;
  return <>{children(data)}</>;
}
Enter fullscreen mode Exit fullscreen mode

Small but real benefit: the component is now narrower-typed because each boolean is independent, so TypeScript narrowing works without discriminated-union gymnastics.

Pattern 2: derive your own richer status enum

If you like a single discriminator in your state machine — because you're doing switch statements, finite-state-machine linting, Redux actions, or similar — build your own enum that captures all five meaningful states:

type QueryPhase =
  | 'initial'    // no data, not fetching (e.g. enabled:false)
  | 'loading'    // no data, fetching
  | 'refreshing' // has data, refetching in background
  | 'success'    // has data, idle
  | 'error';     // error state

function toPhase(result: {
  isPending: boolean;
  isFetching: boolean;
  isError: boolean;
}): QueryPhase {
  if (result.isError) return 'error';
  if (result.isPending && result.isFetching)  return 'loading';
  if (result.isPending && !result.isFetching) return 'initial';
  if (result.isFetching) return 'refreshing';
  return 'success';
}
Enter fullscreen mode Exit fullscreen mode

Then at the component layer:

function Dashboard() {
  const result = useQuery({ queryKey: ['stats'], queryFn: fetchStats });
  const phase = toPhase(result);

  switch (phase) {
    case 'initial':    return <WaitingForDependencies />;
    case 'loading':    return <Spinner />;
    case 'refreshing': return <><Stats data={result.data!} /><FadeBanner /></>;
    case 'success':    return <Stats data={result.data!} />;
    case 'error':      return <ErrorBanner />;
  }
}
Enter fullscreen mode Exit fullscreen mode

The nice thing about this pattern: 'initial' is now a first-class state your UI can handle gracefully (show a "waiting for you to pick an X" placeholder), instead of flashing a spinner forever. That was almost always a v4 bug hidden by the status === 'loading' lie.

Centralise toPhase in one module and you get the benefits of the single discriminator while still being v5-idiomatic underneath.

Pattern 3: keep using status, handle the new meaning explicitly

If you want the v5 semantics (which make sense once you're used to them), just treat status === 'pending' as "no data, for whatever reason" and handle the actively-fetching case separately:

function Dashboard() {
  const { status, fetchStatus, data, error } = useQuery({
    queryKey: ['stats'], queryFn: fetchStats,
  });

  if (status === 'pending') {
    return fetchStatus === 'fetching' ? <Spinner /> : <WaitingForDependencies />;
  }
  if (status === 'error') return <ErrorBanner error={error} />;
  return <Stats data={data} fetching={fetchStatus === 'fetching'} />;
}
Enter fullscreen mode Exit fullscreen mode

The advantage: you preserve the discriminated-union benefit on status (TypeScript narrows data correctly in each branch), and you're explicit about the difference between "loading because we're fetching" vs "loading because we're waiting for input."

The design rationale (why v5 did this)

The thing that helped me make peace with the change: think about what "status" means in a well-designed state machine. It should never lie. status === 'loading' in v4 did lie — it told you the query was loading when actually it was disabled, or waiting on useEffect deps to settle, or paused for network reasons. You couldn't trust status to tell you anything actionable about whether the network was busy.

fetchStatus splits out the "is the network busy" answer so status can be purely about "what data do I have right now?" That makes a bunch of downstream things easier:

  • Offline-first logic gets easier: fetchStatus === 'paused' tells you the query wanted to fetch but couldn't, which used to require inspecting the query cache internals.
  • Optimistic updates get easier: status === 'success' && fetchStatus === 'fetching' is the exact state where your optimistic UI is layered over a background refresh.
  • Suspense compatibility gets easier: Suspense wants a Promise-like "is this resource ready?" signal, which maps cleanly to status === 'pending', independent of whether a network call is happening.

It's the same refactor direction React itself took with <Suspense> / useTransition — separating "is the new state ready?" from "is work in progress?" because they're actually different questions.

Common pitfalls I hit

Three things that cost me time during my migration:

  1. placeholderData changes the contract. If you use placeholderData: keepPreviousData (or the v4 keepPreviousData: true), your query goes status === 'success' immediately with the placeholder data, and fetchStatus === 'fetching' while the real data loads. If your UI was gated on isLoading, it'll now flash the placeholder for a frame before the real data arrives. Sometimes desired, sometimes not — be deliberate.

  2. useSuspenseQuery inverts the mental model. Suspense queries have no pending status — they either throw (which Suspense catches) or return data. If you're mixing Suspense queries with non-Suspense ones in the same component, keep their state handling completely separate; trying to unify them with the same status-branching logic leads to confused code.

  3. The networkMode default changed. In v5, offline queries default to 'online' which means they won't retry while offline — they'll be stuck in fetchStatus === 'paused' until the network returns. That's usually better behaviour, but if you were relying on v4's "retry until we get something" pattern, you need to explicitly opt back in with networkMode: 'always'.

A concrete migration checklist

Because I actually ran this in a team project, here's the order I'd do a TanStack Query v4 → v5 migration on a non-trivial codebase:

  1. Upgrade the package, let the build break.
  2. Global find/replace: status === 'loading'isLoading. Get to compiling.
  3. Audit any switch (status) statements — decide Pattern 1, 2, or 3 per component.
  4. Audit any <XState>-style wrappers that took status as a prop — migrate to boolean-flag props.
  5. Grep for keepPreviousData: true → replace with placeholderData: keepPreviousData (the function, not the boolean).
  6. Check offline UX: if you had offline retry, add networkMode: 'always' where needed.
  7. Run your UI tests. The ones that rely on spinner timing will fail first — they're the ones telling you about the initial state you were previously mis-labelling.

One afternoon of work for a ~100-query codebase, in my experience. Worth it: the new state model is genuinely clearer once you're in it.

References

If you're in the middle of a v5 migration and hit a case this doesn't cover — especially around Suspense queries, infinite queries, or mutation state — drop a comment, I've probably tripped on it.

Top comments (0)