Skip to content

Why Is TanStack Query's data undefined? Seven Causes, Reproduced

Why useQuery returns undefined data in TanStack Query v5: seven causes reproduced on 5.104.5, the state each produces, and the one-line fix for each.

· · 9 min read
Table of seven causes of undefined data in TanStack Query v5 with the status and fetchStatus each one produces

Quick Take

In TanStack Query v5, data is undefined in exactly seven situations I could reproduce: a pending first render, a disabled query, a queryFn that returns nothing, a new queryKey, a select that throws, a mismatched key in getQueryData, and an evicted cache entry. I ran each one against 5.104.5 and recorded the status, the fetchStatus and the error text. Read those two fields first and the cause usually names itself.

Seven situations. That's how many different ways I found data coming back undefined from a TanStack Query v5 hook, and each one needs a different fix. The usual cure, sprinkling data?. across the component, makes the error go quiet and leaves you not knowing which of the seven you're in.

I installed @tanstack/query-core and @tanstack/react-query 5.104.5, the release of 19 October 2026, and ran every case until I could print the exact state. The whole script is in the tanstack-query-data-undefined example. It asserts each result, so it fails loudly if a later release changes one. None of it is a regression in the 5.104 line. Every behavior below matches the documentation, which is exactly why it keeps surprising people.

Read status and fetchStatus before anything else

TanStack Query tracks two separate things. status says whether the query has data: pending (no data yet), error, or success. fetchStatus says whether a request is running: fetching, paused or idle. The queries guide spells the split out, and it's the key to everything that follows.

CausestatusfetchStatuserror message
First renderpendingfetchingnone
enabled: false or skipTokenpendingidlenone
queryFn returns undefinederroridle["void"] data is undefined
New queryKeypendingfetchingnone
select throwserroridlethe TypeError itself
Wrong key in getQueryDatanot applicablenot applicablenone
Entry evictedpendingfetchingnone

Two rows share the same pair, and that's where people get lost. A pending, fetching query is one you wait for. A pending, idle query is one that will never run until something changes.

Decision flow: read error.message on error, fetchStatus on pending, and compare key arrays when getQueryData fails
Where to look first, by status. Drawn from the measured states in the table above.

1. The first render, before the request returns

This is the common one. A query with nothing in the cache starts pending, with data undefined, and the component renders once in that state no matter how fast your API is.

function Profile({ id }: { id: string }) {
  const { data } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) })
  return <p>{data.name}</p>
}

I rendered that with react-dom/server and got TypeError: Cannot read properties of undefined (reading 'name'). TypeScript catches it earlier, if you let it. With strict on and TypeScript 5.9.3, line 3 fails with TS18048: 'data' is possibly 'undefined'.

The fix is to narrow on the status instead of the value:

function Profile({ id }: { id: string }) {
  const query = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) })
  if (query.isPending) {
    return <p>Loading</p>
  }
  if (query.isError) {
    return <p>{query.error.message}</p>
  }
  return <p>{query.data.name}</p>
}

That compiles cleanly with no optional chaining. The result is a discriminated union, so once isPending and isError are handled, TypeScript knows data is a User.

If you'd rather never write the branches, useSuspenseQuery returns data as always defined and moves loading and error handling to a Suspense boundary and an error boundary. I covered that trade in the Suspense data-fetching guide.

2. The query is disabled

Set enabled: false, or pass skipToken as the queryFn, and the query never fetches. In my run both ended at status: 'pending', fetchStatus: 'idle', and isLoading: false.

That last value trips people up. In the library source, isLoading is isPending && isFetching, so a disabled query is pending but not loading. The docs recommend isLoading for a spinner because of that. The catch is what the screen shows instead: a spinner on isPending spins forever for a query that was never going to run, while a spinner on isLoading shows nothing, and data stays undefined behind an empty component. Render an explicit "waiting for an id" state for the disabled case.

const query = useQuery({
  queryKey: ['user', id],
  queryFn: id ? () => fetchUser(id) : skipToken,
})

I prefer skipToken for the "no id yet" case, because TypeScript then lets queryFn take a narrowed id without a non-null assertion. The disabling queries page lists the trade-offs, including that refetch() doesn't work on a skipToken query. Use isPending for "there's no data", and fetchStatus === 'fetching' for "a request is running".

3. The queryFn resolves to undefined

Forget a return and the query fails, with an error that says so in plain terms. My function awaited a call and returned nothing, and the state came back as status: 'error' with the message ["void"] data is undefined. In a development build the library also logs a console error: "Query data cannot be undefined. Please make sure to return a value other than undefined from your query function." The process.env.NODE_ENV !== "production" check sits in the source, so that line disappears in production while the thrown error stays.

// Broken: the braces swallow the value
queryFn: async () => {
  await api.touchUser(id)
}

// Fixed: return something, or null if there's nothing to say
queryFn: async () => {
  await api.touchUser(id)
  return null
}

Return null for "nothing here", or wrap the value in an object. The query functions page says the function must return a promise that resolves data or throws, and the ESLint plugin ships a no-void-query-fn rule that flags a block-bodied queryFn with no return. It costs one line in your lint config, and it catches this before the browser does. If you're on a codebase that already runs a linter gate, add it there.

4. A new queryKey is a new, empty entry

Change the key and you're looking at a different cache entry. If nothing is stored under the new key, the hook goes back to pending and data is undefined, even though the component showed a list a moment ago. I switched from ['page', 1] to ['page', 2] and got data: undefined, status: 'pending', fetchStatus: 'fetching'.

Pagination is where this hurts, because the list flashes empty between pages. placeholderData: keepPreviousData fixes it. With it, my third page showed page-2 while loading, flagged isPlaceholderData: true, then swapped to page-3 when the request landed. The paginated queries page documents the pattern. Watch the flag, though. While isPlaceholderData is true you're looking at the previous page, so a "Next" button that trusts data alone can advance twice.

5. The select function throws

select transforms the cached payload before your component sees it, and it's an easy place to hide a bug. Two things I measured: select isn't called while the query is pending (zero calls before the response, one call after), and an exception inside it doesn't crash the render. It turns into a query error.

select: (payload) => payload.items.length

When the payload had items: undefined, my state was status: 'error', data: undefined, and the error message read Cannot read properties of undefined (reading 'length'). That's the same string as case 1. So if you see this TypeError and your component code looks fine, check query.error before you check the render. Guard inside select, or fix the shape at the source.

6. getQueryData reads a key that was never written

queryClient.getQueryData has nothing to do with hooks. It reads the cache, and a cache key matches only if the whole array matches. After a query stored data under ['todos', 'open'], I read these:

queryClient.getQueryData(['todos', 'open'])             // ['a']
queryClient.getQueryData(['todos', 'Open'])             // undefined
queryClient.getQueryData(['todos'])                     // undefined
queryClient.getQueryData(['todos', 'open', undefined])  // undefined

Three wrong guesses, three undefined results, no warning. Prefix matching works for invalidateQueries and filters, not for reading one entry, which trips people who learned the first and assumed the second. Put your keys in a small factory function and import it everywhere. I'd go further: a getQueryData call with a hand-typed array literal in application code is a smell. If you find yourself writing one, ask whether the data should come through a hook.

7. The entry was evicted

Cached data doesn't live forever. Once nothing observes an entry, it's removed after gcTime. The source sets the default to 300,000 ms (five minutes) in the browser, and Infinity on the server. I set gcTime: 30 for a test, unsubscribed, and waited. getQueryData returned undefined, and mounting the query again started at pending with no data.

queryClient.removeQueries does the same thing on purpose. Both are legitimate, and both explain the "it worked, I went to another route, I came back and it loaded again" report. If five minutes of memory is too short for your screen, raise gcTime for that query. Don't copy it into staleTime, which controls when a refetch happens and has no say over eviction.

Two cases that look broken and aren't

placeholderData makes data defined, and fake. In my run it returned 'draft' with isPlaceholderData: true, while getQueryData still returned undefined, because placeholder data never enters the cache. Code that copies data into a form on first render copies the placeholder.

A failed refetch doesn't clear anything either. A query that had succeeded and then threw on refetch ended with status: 'error' and the earlier data still there. If your screen goes blank on a refetch error, the blanking is your own code treating isError as "no data". Show the old data with a banner instead.

When TanStack Query's data is undefined, read status and fetchStatus first: pending and idle means disabled, pending and fetching means waiting, and error means read error.message.

Share this Post on X Bluesky

What I'd do about it

My opinion, and you can argue with it: ban data?. on query results in code review. It turns a precise state into a shrug, and it's how case 3 sat in a codebase for weeks, because a queryFn that returned nothing looked like an API that returned nothing. Narrow on isPending and isError instead. Use skipToken for conditional queries, add the lint rule for void query functions, and build keys from one factory.

Want the broader picture first? The TanStack Query data-fetching guide covers setup and typing, and the library catalog entry lists the options. If your data is defined and your component still throws on it, you're probably debugging a plain null check, and the TypeScript undefined errors in React state article walks through those.

Which of the seven was behind the last empty screen you debugged? My money is on number two.

Frequently Asked Questions

Why is data undefined in useQuery on the first render?
Because nothing has been fetched yet. A query with no cached entry starts with status 'pending' and data undefined, and it stays that way until the request resolves. In my run on 5.104.5 the first render of a component that read data.name threw 'Cannot read properties of undefined (reading name)'. Check isPending before you touch data, or switch to useSuspenseQuery so data is never undefined.
Why does useQuery show data undefined when enabled is false?
A disabled query never fetches, so it has no data. It sits at status 'pending' with fetchStatus 'idle', and isLoading is false because isLoading means pending and fetching at the same time. A spinner keyed on isPending spins forever for a disabled query, and one keyed on isLoading shows nothing at all, so render an explicit empty state for the disabled case. Test isPending for 'no data yet' and fetchStatus for 'a request is running'.
Does TanStack Query keep the old data when a refetch fails?
Yes. In my test a query that had succeeded once and then failed on refetch ended with status 'error' and the earlier data still in place. Data only disappears when the cache entry is removed, by gcTime, removeQueries or a key change, so an error alone is never why data is undefined after a successful load.