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.
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.
The boundary catches the throw and renders its fallback.
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
isError: true
true
falsy, no throw
After the fix
isError: true
true
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
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.
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?
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?
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?
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?
!= 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.