Skip to content

Commit 7ee784f

Browse files
phatmannlouiscklawTkDodo
authored
feat: Bail out if query data undefined (#3271)
* Bail out if query data undefined * Fix failing test * docs: migration guide for undefined data * docs: update setQueryData reference * Update docs/src/pages/guides/migrating-to-react-query-4.md Co-authored-by: Louis Law <[email protected]> Co-authored-by: Dominik Dorfmeister <[email protected]>
1 parent 2b5c337 commit 7ee784f

13 files changed

+204
-82
lines changed

docs/src/pages/guides/migrating-to-react-query-4.md

+26
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,21 @@ Types now require using TypeScript v4.1 or greater
245245
Starting with v4, react-query will no longer log errors (e.g. failed fetches) to the console in production mode, as this was confusing to many.
246246
Errors will still show up in development mode.
247247

248+
### Undefined is an illegale cache value for successful queries
249+
250+
In order to make bailing out of updates possible by returning `undefined`, we had to make `undefined` an illegal cache value. This is in-line with other concepts of react-query, for example, returning `undefined` from the [initialData function](guides/initial-query-data#initial-data-function) will also _not_ set data.
251+
252+
Further, it is an easy bug to produce `Promise<void>` by adding logging in the queryFn:
253+
254+
```js
255+
useQuery(
256+
['key'],
257+
() => axios.get(url).then(result => console.log(result.data))
258+
)
259+
```
260+
261+
This is now disallowed on type level; at runtime, `undefined` will be transformed to a _failed Promise_, which means you will get an `error`, which will also be logged to the console in development mode.
262+
248263
## New Features 🚀
249264

250265
### Proper offline support
@@ -265,3 +280,14 @@ Mutations can now also be garbage collected automatically, just like queries. Th
265280
### Tracked Queries per default
266281

267282
React Query defaults to "tracking" query properties, which should give you a nice boost in render optimization. The feature has existed since [v3.6.0](https://github.com/tannerlinsley/react-query/releases/tag/v3.6.0) and has now become the default behavior with v4.
283+
284+
### Bailing out of updates with setQueryData
285+
286+
When using the [functional updater form of setQueryData](../reference/QueryClient#queryclientsetquerydata), you can now bail out of the update by returning `undefined`. This is helpful if `undefined` is given to you as `previousValue`, which means that currently, no cached entry exists and you don't want to / cannot create one, like in the example of toggling a todo:
287+
288+
```js
289+
queryClient.setQueryData(
290+
['todo', id],
291+
(previousTodo) => previousTodo ? { ...previousTodo, done: true } : undefined
292+
)
293+
```

docs/src/pages/reference/QueryClient.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ queryClient.setQueryData(queryKey, updater)
214214
**Options**
215215

216216
- `queryKey: QueryKey`: [Query Keys](../guides/query-keys)
217-
- `updater: TData | (oldData: TData | undefined) => TData`
217+
- `updater: TData | (oldData: TData | undefined) => TData | undefined`
218218
- If non-function is passed, the data will be updated to this value
219219
- If a function is passed, it will receive the old data value and be expected to return a new one.
220220

@@ -224,6 +224,8 @@ queryClient.setQueryData(queryKey, updater)
224224
setQueryData(queryKey, newData)
225225
```
226226

227+
If the value is `undefined`, the query data is not updated.
228+
227229
**Using an updater function**
228230

229231
For convenience in syntax, you can also pass an updater function which receives the current data value and returns the new one:
@@ -232,6 +234,8 @@ For convenience in syntax, you can also pass an updater function which receives
232234
setQueryData(queryKey, oldData => newData)
233235
```
234236

237+
If the updater function returns `undefined`, the query data will not be updated. If the updater function receives `undefined` as input, you can return `undefined` to bail out of the update and thus _not_ create a new cache entry.
238+
235239
## `queryClient.getQueryState`
236240

237241
`getQueryState` is a synchronous function that can be used to get an existing query's state. If the query does not exist, `undefined` will be returned.

docs/src/pages/reference/useQuery.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const result = useQuery({
6868
6969
**Options**
7070
71-
- `queryKey: unknown[]`
71+
- `queryKey: unknown[]`
7272
- **Required**
7373
- The query key to use for this query.
7474
- The query key will be hashed into a stable hash. See [Query Keys](../guides/query-keys) for more information.
@@ -77,7 +77,7 @@ const result = useQuery({
7777
- **Required, but only if no default query function has been defined** See [Default Query Function](../guides/default-query-function) for more information.
7878
- The function that the query will use to request data.
7979
- Receives a [QueryFunctionContext](../guides/query-functions#queryfunctioncontext)
80-
- Must return a promise that will either resolve data or throw an error.
80+
- Must return a promise that will either resolve data or throw an error. The data cannot be `undefined`.
8181
- `enabled: boolean`
8282
- Set this to `false` to disable this query from automatically running.
8383
- Can be used for [Dependent Queries](../guides/dependent-queries).

src/core/query.ts

+32-30
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import {
22
getAbortController,
3-
Updater,
4-
functionalUpdate,
53
noop,
64
replaceEqualDeep,
75
timeUntilStale,
@@ -195,14 +193,11 @@ export class Query<
195193
}
196194

197195
setData(
198-
updater: Updater<TData | undefined, TData>,
196+
data: TData,
199197
options?: SetDataOptions & { notifySuccess: boolean }
200198
): TData {
201199
const prevData = this.state.data
202200

203-
// Get the new data
204-
let data = functionalUpdate(updater, prevData)
205-
206201
// Use prev data if an isDataEqual function is defined and returns `true`
207202
if (this.options.isDataEqual?.(prevData, data)) {
208203
data = prevData as TData
@@ -438,11 +433,41 @@ export class Query<
438433
this.dispatch({ type: 'fetch', meta: context.fetchOptions?.meta })
439434
}
440435

436+
const onError = (error: TError | { silent?: boolean }) => {
437+
// Optimistically update state if needed
438+
if (!(isCancelledError(error) && error.silent)) {
439+
this.dispatch({
440+
type: 'error',
441+
error: error as TError,
442+
})
443+
}
444+
445+
if (!isCancelledError(error)) {
446+
// Notify cache callback
447+
this.cache.config.onError?.(error, this as Query<any, any, any, any>)
448+
449+
if (process.env.NODE_ENV !== 'production') {
450+
getLogger().error(error)
451+
}
452+
}
453+
454+
if (!this.isFetchingOptimistic) {
455+
// Schedule query gc after fetching
456+
this.scheduleGc()
457+
}
458+
this.isFetchingOptimistic = false
459+
}
460+
441461
// Try to fetch the data
442462
this.retryer = createRetryer({
443463
fn: context.fetchFn as () => TData,
444464
abort: abortController?.abort?.bind(abortController),
445465
onSuccess: data => {
466+
if (typeof data === 'undefined') {
467+
onError(new Error('Query data cannot be undefined') as any)
468+
return
469+
}
470+
446471
this.setData(data as TData)
447472

448473
// Notify cache callback
@@ -454,30 +479,7 @@ export class Query<
454479
}
455480
this.isFetchingOptimistic = false
456481
},
457-
onError: (error: TError | { silent?: boolean }) => {
458-
// Optimistically update state if needed
459-
if (!(isCancelledError(error) && error.silent)) {
460-
this.dispatch({
461-
type: 'error',
462-
error: error as TError,
463-
})
464-
}
465-
466-
if (!isCancelledError(error)) {
467-
// Notify cache callback
468-
this.cache.config.onError?.(error, this as Query<any, any, any, any>)
469-
470-
if (process.env.NODE_ENV !== 'production') {
471-
getLogger().error(error)
472-
}
473-
}
474-
475-
if (!this.isFetchingOptimistic) {
476-
// Schedule query gc after fetching
477-
this.scheduleGc()
478-
}
479-
this.isFetchingOptimistic = false
480-
},
482+
onError,
481483
onFail: () => {
482484
this.dispatch({ type: 'failed' })
483485
},

src/core/queryClient.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
partialMatchKey,
99
hashQueryKeyByOptions,
1010
MutationFilters,
11+
functionalUpdate,
1112
} from './utils'
1213
import type {
1314
QueryClientConfig,
@@ -125,14 +126,22 @@ export class QueryClient {
125126

126127
setQueryData<TData>(
127128
queryKey: QueryKey,
128-
updater: Updater<TData | undefined, TData>,
129+
updater: Updater<TData | undefined, TData> | undefined,
129130
options?: SetDataOptions
130-
): TData {
131+
): TData | undefined {
132+
const query = this.queryCache.find<TData>(queryKey)
133+
const prevData = query?.state.data
134+
const data = functionalUpdate(updater, prevData)
135+
136+
if (typeof data === 'undefined') {
137+
return undefined
138+
}
139+
131140
const parsedOptions = parseQueryArgs(queryKey)
132141
const defaultedOptions = this.defaultQueryOptions(parsedOptions)
133142
return this.queryCache
134143
.build(this, defaultedOptions)
135-
.setData(updater, { ...options, notifySuccess: false })
144+
.setData(data, { ...options, notifySuccess: false })
136145
}
137146

138147
setQueriesData<TData>(
@@ -151,7 +160,7 @@ export class QueryClient {
151160
queryKeyOrFilters: QueryKey | QueryFilters,
152161
updater: Updater<TData | undefined, TData>,
153162
options?: SetDataOptions
154-
): [QueryKey, TData][] {
163+
): [QueryKey, TData | undefined][] {
155164
return notifyManager.batch(() =>
156165
this.getQueryCache()
157166
.findAll(queryKeyOrFilters)

src/core/tests/query.test.tsx

+27
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
isError,
1313
onlineManager,
1414
QueryFunctionContext,
15+
QueryObserverResult,
1516
} from '../..'
1617
import { waitFor } from '@testing-library/react'
1718

@@ -787,6 +788,7 @@ describe('query', () => {
787788
let signalTest: any
788789
await queryClient.prefetchQuery(key, ({ signal }) => {
789790
signalTest = signal
791+
return 'data'
790792
})
791793

792794
expect(signalTest).toBeUndefined()
@@ -814,6 +816,31 @@ describe('query', () => {
814816
consoleMock.mockRestore()
815817
})
816818

819+
test('fetch should dispatch an error if the queryFn returns undefined', async () => {
820+
const key = queryKey()
821+
822+
const observer = new QueryObserver(queryClient, {
823+
queryKey: key,
824+
queryFn: (() => undefined) as any,
825+
retry: false,
826+
})
827+
828+
let observerResult: QueryObserverResult<unknown, unknown> | undefined
829+
830+
const unsubscribe = observer.subscribe(result => {
831+
observerResult = result
832+
})
833+
834+
await sleep(10)
835+
836+
expect(observerResult).toMatchObject({
837+
isError: true,
838+
error: new Error('Query data cannot be undefined'),
839+
})
840+
841+
unsubscribe()
842+
})
843+
817844
test('fetch should dispatch fetch if is fetching and current promise is undefined', async () => {
818845
const key = queryKey()
819846

0 commit comments

Comments
 (0)