Skip to content

Commit 4e8d2f1

Browse files
committed
refactor: move prevent set state in render logic to useIsFetching hook
1 parent 58acca1 commit 4e8d2f1

File tree

5 files changed

+117
-44
lines changed

5 files changed

+117
-44
lines changed

src/core/queryCache.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
Console,
66
isObject,
77
Updater,
8+
functionalUpdate,
89
} from './utils'
910
import { defaultConfigRef, ReactQueryConfigRef } from './config'
1011
import { Query } from './query'
@@ -268,17 +269,7 @@ export class QueryCache {
268269

269270
if (!this.config.frozen) {
270271
this.queries[queryHash] = query
271-
272-
if (isServer) {
273-
this.notifyGlobalListeners()
274-
} else {
275-
// Here, we setTimeout so as to not trigger
276-
// any setState's in parent components in the
277-
// middle of the render phase.
278-
setTimeout(() => {
279-
this.notifyGlobalListeners()
280-
})
281-
}
272+
this.notifyGlobalListeners(query)
282273
}
283274
}
284275

@@ -388,13 +379,18 @@ export class QueryCache {
388379
updater: Updater<TResult | undefined, TResult>,
389380
config: QueryConfig<TResult, TError> = {}
390381
) {
391-
let query = this.getQuery<TResult, TError>(queryKey)
382+
const query = this.getQuery<TResult, TError>(queryKey)
392383

393-
if (!query) {
394-
query = this.buildQuery<TResult, TError>(queryKey, config)
384+
if (query) {
385+
query.setData(updater)
386+
return
395387
}
396388

397-
query.setData(updater)
389+
this.buildQuery<TResult, TError>(queryKey, {
390+
initialStale: typeof config.staleTime === 'undefined',
391+
initialData: functionalUpdate(updater, undefined),
392+
...config,
393+
})
398394
}
399395
}
400396

src/react/tests/useInfiniteQuery.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ describe('useInfiniteQuery', () => {
182182
rendered.getByText('Page 0: 0')
183183
})
184184

185-
await fireEvent.click(rendered.getByText('Load More'))
185+
fireEvent.click(rendered.getByText('Load More'))
186186

187187
await waitFor(() => rendered.getByText('Loading more...'))
188188

src/react/tests/useIsFetching.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as React from 'react'
33

44
import { useQuery, useIsFetching } from '../index'
55
import { sleep, queryKey } from './utils'
6+
import { useQueryCache } from '../ReactQueryCacheProvider'
67

78
describe('useIsFetching', () => {
89
// See https://github.com/tannerlinsley/react-query/issues/105
@@ -40,4 +41,49 @@ describe('useIsFetching', () => {
4041
await waitFor(() => rendered.getByText('isFetching: 1'))
4142
await waitFor(() => rendered.getByText('isFetching: 0'))
4243
})
44+
45+
it('should not update state while rendering', async () => {
46+
const spy = jest.spyOn(console, 'error')
47+
48+
const key1 = queryKey()
49+
const key2 = queryKey()
50+
const key3 = queryKey()
51+
52+
const isFetchings: number[] = []
53+
54+
function Child() {
55+
const isFetching = useIsFetching()
56+
isFetchings.push(isFetching)
57+
return null
58+
}
59+
60+
function Page() {
61+
const queryCache = useQueryCache()
62+
63+
useQuery(key1, async () => {
64+
await sleep(100)
65+
return 'data'
66+
})
67+
68+
useQuery(key2, async () => {
69+
await sleep(10)
70+
return 'data'
71+
})
72+
73+
if (isFetchings.length === 1) {
74+
// Set query data in the second render to force a
75+
// state update in the Child component
76+
queryCache.setQueryData(key3, () => 'data')
77+
}
78+
79+
return <Child />
80+
}
81+
82+
render(<Page />)
83+
await waitFor(() => expect(isFetchings).toEqual([2, 2, 1, 1, 1, 0]))
84+
expect(spy).not.toHaveBeenCalled()
85+
expect(spy.mock.calls[0]?.[0] ?? '').not.toMatch('setState')
86+
87+
spy.mockRestore()
88+
})
4389
})

src/react/useIsFetching.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,19 @@
11
import React from 'react'
22

3-
import { useRerenderer, useGetLatest } from './utils'
43
import { useQueryCache } from './ReactQueryCacheProvider'
4+
import { useSafeState } from './utils'
55

66
export function useIsFetching(): number {
77
const queryCache = useQueryCache()
8-
const rerender = useRerenderer()
9-
const isFetching = queryCache.isFetching
108

11-
const getIsFetching = useGetLatest(isFetching)
9+
const [isFetching, setIsFetching] = useSafeState(queryCache.isFetching)
1210

1311
React.useEffect(
1412
() =>
15-
queryCache.subscribe(newCache => {
16-
if (getIsFetching() !== newCache.isFetching) {
17-
rerender()
18-
}
13+
queryCache.subscribe(() => {
14+
setIsFetching(queryCache.isFetching)
1915
}),
20-
[getIsFetching, queryCache, rerender]
16+
[queryCache, setIsFetching]
2117
)
2218

2319
return isFetching

src/react/utils.ts

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,78 @@
11
import React from 'react'
22

3-
import { uid, isServer } from '../core/utils'
3+
import { isServer } from '../core/utils'
44
import { QueryResultBase, BaseQueryConfig, QueryStatus } from '../core/types'
55

6-
export function useUid(): number {
7-
const ref = React.useRef(0)
8-
9-
if (ref.current === null) {
10-
ref.current = uid()
11-
}
12-
13-
return ref.current
14-
}
15-
166
export function useGetLatest<T>(obj: T): () => T {
177
const ref = React.useRef<T>(obj)
188
ref.current = obj
199
return React.useCallback(() => ref.current, [])
2010
}
2111

22-
export function useMountedCallback<T extends Function>(callback: T): T {
23-
const mounted = React.useRef(false)
12+
function useIsMounted(): () => boolean {
13+
const mountedRef = React.useRef(false)
14+
const isMounted = React.useCallback(() => mountedRef.current, [])
2415

2516
React[isServer ? 'useEffect' : 'useLayoutEffect'](() => {
26-
mounted.current = true
17+
mountedRef.current = true
2718
return () => {
28-
mounted.current = false
19+
mountedRef.current = false
2920
}
3021
}, [])
3122

23+
return isMounted
24+
}
25+
26+
export function useMountedCallback<T extends Function>(callback: T): T {
27+
const isMounted = useIsMounted()
3228
return (React.useCallback(
33-
(...args: any[]) => (mounted.current ? callback(...args) : void 0),
34-
[callback]
29+
(...args: any[]) => (isMounted() ? callback(...args) : void 0),
30+
[callback, isMounted]
3531
) as any) as T
3632
}
3733

34+
/**
35+
* This hook is a safe useState version which schedules state updates in microtasks
36+
* to prevent updating a component state while React is rendering parent components
37+
* or when the component is not mounted anymore.
38+
*/
39+
export function useSafeState<S>(
40+
initialState: S | (() => S)
41+
): [S, React.Dispatch<React.SetStateAction<S>>] {
42+
const isMounted = useIsMounted()
43+
const [state, setState] = React.useState(initialState)
44+
45+
const safeSetState = React.useCallback(
46+
(value: React.SetStateAction<S>) => {
47+
scheduleMicrotask(() => {
48+
if (isMounted()) {
49+
setState(value)
50+
}
51+
})
52+
},
53+
[isMounted]
54+
)
55+
56+
return [state, safeSetState]
57+
}
58+
3859
export function useRerenderer() {
39-
const rerender = useMountedCallback(React.useState<unknown>()[1])
40-
return React.useCallback(() => rerender({}), [rerender])
60+
const [, setState] = useSafeState({})
61+
return React.useCallback(() => setState({}), [setState])
62+
}
63+
64+
/**
65+
* Schedules a microtask.
66+
* This can be useful to prevent updating state while rendering.
67+
*/
68+
function scheduleMicrotask(callback: () => void): void {
69+
Promise.resolve()
70+
.then(callback)
71+
.catch(error =>
72+
setTimeout(() => {
73+
throw error
74+
})
75+
)
4176
}
4277

4378
export function handleSuspense(

0 commit comments

Comments
 (0)