The error that was too falsy to throw

TanStack Query pull request 11305 deletes seven characters from one line. Those seven characters were deciding that some errors did not count.

TanStack/query PR 11305 at fc00bf4, explained 2026-09-01

Background

New to React Query or to error boundaries? Expand for the deeper background.

React Query fetches data for you. You give it a key and a function that returns a promise, and it gives you back an object describing the state of that fetch: isPending, isError, data, error.

const { data, isError, error } = useQuery({
  queryKey: ['user', 7],
  queryFn: () => fetch('/api/user/7').then((r) => r.json()),
})

By default a failure is just state. Your component checks isError and renders whatever it likes. That is fine for one widget. Across forty it is tiresome, because every component grows the same error branch.

React offers another way. An error boundary is a component that catches an exception thrown by anything below it during render, and shows a fallback instead of a blank screen. One boundary can cover a whole page.

To connect the two, React Query takes an option called throwOnError. Set it, and a failed query stops being quiet state and instead throws during render, where the nearest boundary catches it. The component no longer needs an error branch. That is the feature this change is about.

One last piece of vocabulary. JavaScript calls a value falsy when it converts to false in a boolean test. There are seven: false, 0, -0, 0n, '', null, and undefined. Everything else is truthy. This distinction drives everything below.

The library exposes several hooks that share one observer. useQuery runs a single query through a wrapper called useBaseQuery. useQueries runs a list of them and returns an array of results. useSuspenseQueries is useQueries with suspense turned on and throwOnError fixed to a default. All three read from the same observer in query-core, and all three decide whether to throw using the same helper.

That helper is getHasError. It is the single place that answers one question: should this result be thrown to the error boundary rather than returned as state?

packages/react-query/src/errorBoundaryUtils.ts:70-77

return (
  result.isError &&
  !errorResetBoundary.isReset() &&
  !result.isFetching &&
  query &&
  ((suspense && result.data === undefined) ||
    shouldThrowError(throwOnError, [result.error, query]))
)

Read what it looks at. The status flag result.isError. Whether the boundary has been reset. Whether a fetch is in flight. Whether the query exists in the cache. Then either the suspense condition or the user's throwOnError setting, resolved by shouldThrowError.

Note what it does not look at: the error value. The value is passed onward, at line 76, so that a user-supplied throwOnError function can inspect it and decide. But getHasError itself never checks whether the error is truthy. It gates on isError, a boolean the query state already owns.

Key concept getHasError is the decision. Everything downstream of it is supposed to be execution. When a second check re-derives that answer, you have two answers, and eventually they disagree.

Intuition

Here is the shape of the bug in one sentence. A query that fails with a falsy error reached the boundary from useQuery and did not reach it from useQueries.

Both hooks throw during render. useQuery throws on getHasError and nothing else: its throw site at useBaseQuery.ts:127-137 has exactly one condition, and that condition is the helper. useQueries throws on getHasError and a truthiness check on the error value the helper just ruled on. Most of the time those two conditions agree, because most errors are Error objects and every object is truthy. So the second check does no visible harm.

It stops being harmless the moment something rejects with a falsy value. That is not exotic. Promise.reject() with no argument rejects with undefined, and a bare reject() in a hand-rolled promise does the same.

What the queryFn rejects with getHasError Old guard passed Boundary shown
new Error('nope') true yes yes
'nope' true yes yes
Promise.reject() true no no
null true no no
0 true no no
'' true no no

Every row has getHasError returning true. The library agreed, six times out of six, that the result should be thrown. In four of them it was not. The only thing separating the two groups is whether the rejected value happens to be truthy.

What the developer saw

Not a crash. Something worse than a crash, because a crash tells you where to look. The component rendered as though nothing had happened.

Expected, and what useQuery does
Something went wrong. Try again.

The boundary catches the throw and renders its fallback.

Actual, from useQueries
No results found.

No throw, so no fallback. The component runs its normal path with data undefined, and renders its empty state. The failure is now indistinguishable from success with nothing in it.

Following one value through

Take queryFn: () => Promise.reject() with throwOnError: true and retry: false. The rejection value is undefined. Watch it move.

Before the fix

queryFn rejects
undefined
query state
isError: true
result
getHasError
true
?.error
undefined
falsy, no throw

After the fix

queryFn rejects
undefined
query state
isError: true
result
getHasError
true
found a result
throw undefined
boundary catches

The last step looks wrong at first. throw undefined is legal. throw in JavaScript accepts any value, not only an Error, and React hands whatever was thrown to the nearest boundary. Throwing undefined is exactly as valid as throwing an Error, and the fallback renders either way. The test added in this pull request proves it.

The same story, with the timing made explicit. Note where the guard sits: after the decision, not before it.

sequenceDiagram
    autonumber
    participant F as queryFn
    participant O as QueryObserver
    participant U as useQueries
    participant G as getHasError
    participant B as ErrorBoundary

    F-->>O: reject(undefined)
    O->>O: set isError true and error undefined
    U->>O: getOptimisticResult()
    O-->>U: array of results
    U->>G: getHasError(result 0)
    G-->>U: true. this one should throw
    Note over U: the guard sits here.<br/>asking result.error again<br/>overrules getHasError
    U->>B: throw result.error
    B->>B: render the fallback
Edge case The guard was not pointless. Something has to stand there. Array.prototype.find returns QueryObserverResult | undefined, so you cannot reach .error without first establishing that you found anything. The fix keeps that null check and drops only the second job the old expression had taken on. One check, one question.

Code walkthrough

The flow, end to end: a queryFn rejects, the observer records the failure, getHasError decides it should be thrown, useQueries throws it, the boundary renders a fallback. Five files changed; one of them is the fix and three are tests.

1. The decision, unchanged

Nothing in this pull request touches getHasError or shouldThrowError. They are here because you cannot see what the fix does without them. shouldThrowError is how a boolean or a function throwOnError collapses into one answer.

packages/query-core/src/utils.ts:470-480

export function shouldThrowError<T extends (...args: Array<any>) => boolean>(
  throwOnError: boolean | T | undefined,
  params: Parameters<T>,
): boolean {
  // Allow throwOnError function to override throwing behavior on a per-error basis
  if (typeof throwOnError === 'function') {
    return throwOnError(...params)
  }

  return !!throwOnError
}

A user-supplied function does get the error value, at errorBoundaryUtils.ts:76, and may refuse the throw on that basis. Note where that happens: inside getHasError, folded into the one decision, rather than as a second gate after it.

2. The throw site, and the change

useQueries asks the observer for every result, then looks for the first one that should throw. The results arrive as optimisticResult, the observer's best current answer for each query, computed synchronously during render so the hook has something to return before any fetch settles.

packages/react-query/src/useQueries.ts:310-328

const firstSingleResultWhichShouldThrow = optimisticResult.find(
  (result, index) => {
    const query = defaultedQueries[index]
    return (
      query &&
      getHasError({
        result,
        errorResetBoundary,
        throwOnError: query.throwOnError,
        query: client.getQueryCache().get(query.queryHash),
        suspense: query.suspense,
      })
    )
  },
)

-if (firstSingleResultWhichShouldThrow?.error) {
+if (firstSingleResultWhichShouldThrow) {
   throw firstSingleResultWhichShouldThrow.error
 }

The variable name states the intent plainly. It holds the first result which should throw. By the time the if runs, getHasError has already settled the question. Asking ?.error re-opens it, and answers it with a weaker check than the one getHasError just applied.

The predicate explains why find forces a check at all. The monorepo's root tsconfig.json sets noUncheckedIndexedAccess, so defaultedQueries[index] is possibly undefined. The query && guard on the third line covers that. Then find hands back either a result or undefined, and that is the null check the surviving condition performs.

3. The reference implementation

useQuery routes through useBaseQuery. This is the comparison that shows the bug is asymmetric: useQuery never had it.

packages/react-query/src/useBaseQuery.ts:126-137

// Handle error boundary
if (
  getHasError({
    result,
    errorResetBoundary,
    throwOnError: defaultedOptions.throwOnError,
    query,
    suspense: defaultedOptions.suspense,
  })
) {
  throw result.error
}

One condition, and it is the helper. No truthiness check on the value. No null check either: result is a single object from the observer, not the outcome of a search, so nothing here can be undefined.

This is what grounds the fix. The change brings useQueries into line with the behavior useQuery has always had, so nobody had to decide afresh how falsy errors ought to work. The pull request author says as much: the new useQuery test is labeled "reference behaviour, passes before and after".

4. Where the fix travels for free

packages/react-query/src/useSuspenseQueries.ts:189-211

export function useSuspenseQueries(options: any, queryClient?: QueryClient) {
  return useQueries(
    {
      ...options,
      queries: options.queries.map((query: any) => {
        // elided: a development-mode console.error for skipToken misuse
        return {
          ...query,
          suspense: true,
          throwOnError: defaultThrowOnError,
          enabled: true,
          placeholderData: undefined,
        }
      }),
    },
    queryClient,
  )
}

useSuspenseQueries is a thin wrapper. It rewrites each query's options and delegates. It has no throw site of its own, so it inherited the bug and inherits the fix, with no edit to this file. That is why the changeset names two hooks while the diff touches one.

5. The tests

Three test files, one each for useQuery, useQueries, and useSuspenseQueries. The order matters more than the count.

packages/react-query/src/__tests__/useQuery.test.tsx:2776

it('should throw error if queryFn rejects with a falsy error and throwOnError is in use', ...

That one passes before the fix. It catches no regression in useQuery. It records the behavior the other two tests must match, so a reader six months from now can check the reference instead of assuming it.

packages/react-query/src/__tests__/useQueries.test.tsx:287-289

queryFn: () => Promise.reject(),
retry: false,
throwOnError: true,

Each of the three options does real work. Promise.reject() supplies the falsy value. throwOnError: true asks for the boundary at all. retry: false stops the query retrying, which matters twice over: getHasError refuses to throw while a fetch is in flight, at errorBoundaryUtils.ts:73, and a retrying query still reports pending status, so result.isError would be false too. The test advances fake timers by zero, which would never clear a retry backoff. Remove any one option and the test passes for the wrong reason.

The useSuspenseQueries test at useSuspenseQueries.test.tsx:580 sets no throwOnError, because suspense supplies its own default, and rejects after a delay so the assertion can watch the fallback replace the loading state.

.changeset/olive-donuts-shave.md

---
'@tanstack/react-query': patch
---

fix(react-query): throw falsy errors from `useQueries` and `useSuspenseQueries` to the error boundary

Marked patch, and that is not obvious. Code that silently swallowed a falsy error will now see it thrown, so someone somewhere gets a fallback where they used to get an empty state. The changeset gives no rationale for the label, so this next part is my reading rather than the maintainers': a patch looks right here, because the documented option never promised to swallow falsy errors and useQuery already threw them.

The transferable part A guard that re-derives a decision already made upstream is a latent bug. It stays latent as long as the two conditions agree. When you find yourself writing if (x?.someField), ask which thing you need: x to exist, or someField to be truthy. If it is the first, write that instead. One day a legitimate value will be falsy, and the difference will surface as a bug nobody can see.

Quiz

Five questions about why the change is shaped the way it is. Click an option to see the answer.

1. The fix leaves if (firstSingleResultWhichShouldThrow). A reviewer proposes if (firstSingleResultWhichShouldThrow !== undefined) as clearer about its intent. Does that change what the code does?

This is the distinction the original bug turned on, now harmless. Once the condition checks the result object instead of a field on it, truthiness and an explicit undefined comparison cannot diverge, because no object is falsy. That was not true of ?.error, where the field could be falsy while the object itself was fine. And find returns undefined for no match, never null.

2. Before the fix, a component calls useQueries with one query: queryFn: () => Promise.reject(0), retry: false, throwOnError: true, inside an error boundary. What does the user see?

0 is falsy, so the old guard skipped the throw. The query did settle and did record the error; the component simply never heard about it and rendered its normal path. That silence is what made this hard to notice.

3. useQuery never had this bug. Why not?

Both hooks call the same helper. They differ only in what each does with the answer. useQuery acts on it; useQueries added a second condition on top of it.

4. Suppose someone reads the fixed line, decides the check is now redundant since the variable is named "should throw", and simplifies to throw firstSingleResultWhichShouldThrow.error with no if. What happens?

The check is doing real work: it distinguishes "found one" from "found none", and the common case is none. Property access on undefined does not short-circuit; only optional chaining does, which is precisely the conflation the fix untangled.

5. An alternative fix: leave useQueries alone and change getHasError to require result.error != null alongside result.isError. Does that solve the reported problem?

The third option is half right, and that is what makes it tempting: != null does admit 0 and ''. It still rejects the Promise.reject() case, which is the case the bug report was about. The deeper problem is direction. Putting the value check in the shared helper spreads the defect to the hook that was working, instead of removing it from the hook that was not.