Skip to content

Commit 1278dfc

Browse files
committed
feat: add notifyOnStatusChange flag
1 parent 8f6bdf3 commit 1278dfc

File tree

11 files changed

+193
-59
lines changed

11 files changed

+193
-59
lines changed

docs/src/pages/docs/api.md

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,40 +7,41 @@ title: API Reference
77

88
```js
99
const {
10-
status,
11-
isIdle,
12-
isLoading,
13-
isSuccess,
14-
isError,
10+
clear,
1511
data,
1612
error,
17-
isStale,
18-
isFetching,
1913
failureCount,
14+
isError,
15+
isFetching,
16+
isIdle,
17+
isLoading,
18+
isStale,
19+
isSuccess,
2020
refetch,
21-
clear,
21+
status,
2222
} = useQuery(queryKey, queryFn?, {
23-
suspense,
24-
queryKeySerializerFn,
25-
enabled,
26-
retry,
27-
retryDelay,
28-
staleTime,
2923
cacheTime,
24+
enabled,
25+
initialData,
26+
initialStale,
27+
isDataEqual,
3028
keepPreviousData,
31-
refetchOnWindowFocus,
32-
refetchOnReconnect,
29+
notifyOnStatusChange,
30+
onError,
31+
onSettled,
32+
onSuccess,
33+
queryFnParamsFilter,
34+
queryKeySerializerFn,
3335
refetchInterval,
3436
refetchIntervalInBackground,
35-
queryFnParamsFilter,
3637
refetchOnMount,
38+
refetchOnReconnect,
39+
refetchOnWindowFocus,
40+
retry,
41+
retryDelay,
42+
staleTime,
3743
structuralSharing,
38-
isDataEqual,
39-
onError,
40-
onSuccess,
41-
onSettled,
42-
initialData,
43-
initialStale,
44+
suspense,
4445
useErrorBoundary,
4546
})
4647

@@ -96,6 +97,11 @@ const queryInfo = useQuery({
9697
- `refetchOnReconnect: Boolean`
9798
- Optional
9899
- Set this to `true` or `false` to enable/disable automatic refetching on reconnect for this query.
100+
- `notifyOnStatusChange: Boolean`
101+
- Optional
102+
- Whether a change to the query status should re-render a component.
103+
- If set to `false`, the component will only re-render when the actual `data` or `error` changes.
104+
- Defaults to `true`.
99105
- `onSuccess: Function(data) => data`
100106
- Optional
101107
- This function will fire any time the query successfully fetches new data.

src/core/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const DEFAULT_CONFIG: ReactQueryConfig = {
5858
refetchOnWindowFocus: true,
5959
refetchOnReconnect: true,
6060
refetchOnMount: true,
61+
notifyOnStatusChange: true,
6162
structuralSharing: true,
6263
},
6364
}

src/core/query.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ export class Query<TResult, TError> {
469469
const config = this.config
470470

471471
// Check if there is a query function
472-
if (!config.queryFn) {
472+
if (typeof config.queryFn !== 'function') {
473473
return
474474
}
475475

src/core/queryCache.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,6 @@ export class QueryCache {
313313
if (options?.throwOnError) {
314314
throw error
315315
}
316-
return
317316
} finally {
318317
if (query) {
319318
// When prefetching, no observer is tied to the query,

src/core/queryObserver.ts

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -157,39 +157,32 @@ export class QueryObserver<TResult, TError> {
157157

158158
private createResult(): QueryResult<TResult, TError> {
159159
const { currentQuery, previousResult, config } = this
160-
161-
const {
162-
canFetchMore,
163-
error,
164-
failureCount,
165-
isFetched,
166-
isFetching,
167-
isFetchingMore,
168-
isLoading,
169-
isStale,
170-
} = currentQuery.state
171-
172-
let { data, status, updatedAt } = currentQuery.state
160+
const { state } = currentQuery
161+
let { data, status, updatedAt } = state
173162

174163
// Keep previous data if needed
175-
if (config.keepPreviousData && isLoading && previousResult?.isSuccess) {
164+
if (
165+
config.keepPreviousData &&
166+
state.isLoading &&
167+
previousResult?.isSuccess
168+
) {
176169
data = previousResult.data
177170
updatedAt = previousResult.updatedAt
178171
status = previousResult.status
179172
}
180173

181174
return {
182175
...getStatusProps(status),
183-
canFetchMore,
176+
canFetchMore: state.canFetchMore,
184177
clear: this.clear,
185178
data,
186-
error,
187-
failureCount,
179+
error: state.error,
180+
failureCount: state.failureCount,
188181
fetchMore: this.fetchMore,
189-
isFetched,
190-
isFetching,
191-
isFetchingMore,
192-
isStale,
182+
isFetched: state.isFetched,
183+
isFetching: state.isFetching,
184+
isFetchingMore: state.isFetchingMore,
185+
isStale: state.isStale,
193186
query: currentQuery,
194187
refetch: this.refetch,
195188
updatedAt,
@@ -229,20 +222,35 @@ export class QueryObserver<TResult, TError> {
229222
_state: QueryState<TResult, TError>,
230223
action: Action<TResult, TError>
231224
): void {
232-
this.currentResult = this.createResult()
225+
const { config } = this
233226

234-
const { data, error, isSuccess, isError } = this.currentResult
227+
// Store current result and get new result
228+
const prevResult = this.currentResult
229+
this.currentResult = this.createResult()
230+
const result = this.currentResult
235231

236-
if (action.type === 'Success' && isSuccess) {
237-
this.config.onSuccess?.(data!)
238-
this.config.onSettled?.(data!, null)
232+
// We need to check the action because the state could have
233+
// transitioned from success to success in case of `setQueryData`.
234+
if (action.type === 'Success' && result.isSuccess) {
235+
config.onSuccess?.(result.data!)
236+
config.onSettled?.(result.data!, null)
239237
this.updateRefetchInterval()
240-
} else if (action.type === 'Error' && isError) {
241-
this.config.onError?.(error!)
242-
this.config.onSettled?.(undefined, error!)
238+
} else if (action.type === 'Error' && result.isError) {
239+
config.onError?.(result.error!)
240+
config.onSettled?.(undefined, result.error!)
243241
this.updateRefetchInterval()
244242
}
245243

246-
this.updateListener?.(this.currentResult)
244+
// Decide if we need to notify the listener
245+
const notify =
246+
// Always notify on data or error change
247+
result.data !== prevResult.data ||
248+
result.error !== prevResult.error ||
249+
// Maybe notify on other changes
250+
config.notifyOnStatusChange
251+
252+
if (notify) {
253+
this.updateListener?.(result)
254+
}
247255
}
248256
}

src/core/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ export interface QueryObserverConfig<
105105
* Defaults to `true`.
106106
*/
107107
refetchOnMount?: boolean
108+
/**
109+
* Whether a change to the query status should re-render a component.
110+
* If set to `false`, the component will only re-render when the actual `data` or `error` changes.
111+
* Defaults to `true`.
112+
*/
113+
notifyOnStatusChange?: boolean
108114
/**
109115
* This callback will fire any time the query successfully fetches new data.
110116
*/

src/react/tests/useInfiniteQuery.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('useInfiniteQuery', () => {
6060
await waitFor(() => rendered.getByText('Status: success'))
6161

6262
expect(states[0]).toEqual({
63-
canFetchmore: undefined,
63+
canFetchMore: undefined,
6464
clear: expect.any(Function),
6565
data: undefined,
6666
error: null,

src/react/tests/useQuery.test.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,111 @@ describe('useQuery', () => {
461461
})
462462
})
463463

464+
it('should re-render when a query becomes stale', async () => {
465+
const key = queryKey()
466+
const states: QueryResult<string>[] = []
467+
468+
function Page() {
469+
const state = useQuery(key, () => 'test', {
470+
staleTime: 50,
471+
})
472+
states.push(state)
473+
return null
474+
}
475+
476+
render(<Page />)
477+
478+
await waitFor(() => expect(states.length).toBe(3))
479+
480+
expect(states[0]).toMatchObject({
481+
isStale: true,
482+
})
483+
expect(states[1]).toMatchObject({
484+
isStale: false,
485+
})
486+
expect(states[2]).toMatchObject({
487+
isStale: true,
488+
})
489+
})
490+
491+
it('should not re-render when a query status changes and notifyOnStatusChange is false', async () => {
492+
const key = queryKey()
493+
494+
let count = 0
495+
const fn = () => sleep(10).then(() => count++)
496+
497+
const states1: QueryResult<number>[] = []
498+
const states2: QueryResult<number>[] = []
499+
500+
function FirstCounter() {
501+
const state = useQuery(key, fn, {
502+
notifyOnStatusChange: false,
503+
})
504+
states1.push(state)
505+
return null
506+
}
507+
508+
function SecondCounter() {
509+
const state = useQuery(key, fn)
510+
states2.push(state)
511+
return null
512+
}
513+
514+
function Footer() {
515+
const [showCounter, setShowCounter] = React.useState(false)
516+
517+
React.useEffect(() => {
518+
setTimeout(() => {
519+
setShowCounter(true)
520+
}, 20)
521+
}, [])
522+
523+
return <div>{showCounter && <SecondCounter />}</div>
524+
}
525+
526+
function Page() {
527+
return (
528+
<div>
529+
<FirstCounter />
530+
<Footer />
531+
</div>
532+
)
533+
}
534+
535+
render(<Page />)
536+
537+
await waitFor(() => expect(states1[2]?.data).toBe(1))
538+
539+
expect(states1.length).toBe(3)
540+
541+
expect(states1[0]).toMatchObject({
542+
status: 'loading',
543+
isFetching: true,
544+
})
545+
expect(states1[1]).toMatchObject({
546+
status: 'success',
547+
isFetching: false,
548+
})
549+
expect(states1[2]).toMatchObject({
550+
status: 'success',
551+
isFetching: false,
552+
})
553+
554+
expect(states2.length).toBe(3)
555+
expect(states2[0]).toMatchObject({
556+
status: 'success',
557+
isFetching: false,
558+
})
559+
expect(states2[1]).toMatchObject({
560+
status: 'success',
561+
isFetching: true,
562+
})
563+
expect(states2[2]).toMatchObject({
564+
status: 'success',
565+
isFetching: false,
566+
})
567+
})
568+
464569
// See https://github.com/tannerlinsley/react-query/issues/137
465570
it('should not override initial data in dependent queries', async () => {
466571
const key1 = queryKey()

src/react/tests/utils.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { waitFor } from '@testing-library/react'
2+
13
let queryKeyCount = 0
24

35
export function mockVisibilityState(value: string) {
@@ -31,6 +33,15 @@ export function sleep(timeout: number): Promise<void> {
3133
})
3234
}
3335

36+
export function waitForMs(ms: number) {
37+
const end = Date.now() + ms
38+
return waitFor(() => {
39+
if (Date.now() < end) {
40+
throw new Error('Time not elapsed yet')
41+
}
42+
})
43+
}
44+
3445
/**
3546
* Checks that `T` is of type `U`.
3647
*/

src/react/useMutation.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,6 @@ export function useMutation<
172172
if (mutateConfig.throwOnError ?? config.throwOnError) {
173173
throw error
174174
}
175-
176-
return
177175
}
178176
},
179177
[dispatch, getConfig, getMutationFn]

0 commit comments

Comments
 (0)