Skip to content

Commit 15f53ca

Browse files
authored
refactor: move prevent set state in render logic to useIsFetching hook (#874)
1 parent 73ccba8 commit 15f53ca

File tree

5 files changed

+131
-44
lines changed

5 files changed

+131
-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 { getDefaultedQueryConfig } from './config'
1011
import { Query } from './query'
@@ -231,17 +232,7 @@ export class QueryCache {
231232

232233
if (!this.config.frozen) {
233234
this.queries[queryHash] = query
234-
235-
if (isServer) {
236-
this.notifyGlobalListeners()
237-
} else {
238-
// Here, we setTimeout so as to not trigger
239-
// any setState's in parent components in the
240-
// middle of the render phase.
241-
setTimeout(() => {
242-
this.notifyGlobalListeners()
243-
})
244-
}
235+
this.notifyGlobalListeners(query)
245236
}
246237
}
247238

@@ -333,13 +324,18 @@ export class QueryCache {
333324
updater: Updater<TResult | undefined, TResult>,
334325
config?: QueryConfig<TResult, TError>
335326
) {
336-
let query = this.getQuery<TResult, TError>(queryKey)
327+
const query = this.getQuery<TResult, TError>(queryKey)
337328

338-
if (!query) {
339-
query = this.buildQuery<TResult, TError>(queryKey, config)
329+
if (query) {
330+
query.setData(updater)
331+
return
340332
}
341333

342-
query.setData(updater)
334+
this.buildQuery<TResult, TError>(queryKey, {
335+
initialStale: typeof config?.staleTime === 'undefined',
336+
initialData: functionalUpdate(updater, undefined),
337+
...config,
338+
})
343339
}
344340
}
345341

src/react/tests/useIsFetching.test.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,60 @@ describe('useIsFetching', () => {
4040
await waitFor(() => rendered.getByText('isFetching: 1'))
4141
await waitFor(() => rendered.getByText('isFetching: 0'))
4242
})
43+
44+
it('should not update state while rendering', async () => {
45+
const spy = jest.spyOn(console, 'error')
46+
47+
const key1 = queryKey()
48+
const key2 = queryKey()
49+
50+
const isFetchings: number[] = []
51+
52+
function IsFetching() {
53+
const isFetching = useIsFetching()
54+
isFetchings.push(isFetching)
55+
return null
56+
}
57+
58+
function FirstQuery() {
59+
useQuery(key1, async () => {
60+
await sleep(100)
61+
return 'data'
62+
})
63+
return null
64+
}
65+
66+
function SecondQuery() {
67+
useQuery(key2, async () => {
68+
await sleep(100)
69+
return 'data'
70+
})
71+
return null
72+
}
73+
74+
function Page() {
75+
const [renderSecond, setRenderSecond] = React.useState(false)
76+
77+
React.useEffect(() => {
78+
setTimeout(() => {
79+
setRenderSecond(true)
80+
}, 10)
81+
}, [])
82+
83+
return (
84+
<>
85+
<FirstQuery />
86+
{renderSecond && <SecondQuery />}
87+
<IsFetching />
88+
</>
89+
)
90+
}
91+
92+
render(<Page />)
93+
await waitFor(() => expect(isFetchings).toEqual([1, 1, 2, 1, 0]))
94+
expect(spy).not.toHaveBeenCalled()
95+
expect(spy.mock.calls[0]?.[0] ?? '').not.toMatch('setState')
96+
97+
spy.mockRestore()
98+
})
4399
})

src/react/useBaseQuery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function useBaseQuery<TResult, TError>(
2323
React.useEffect(
2424
() =>
2525
observer.subscribe(() => {
26-
Promise.resolve().then(rerender)
26+
rerender()
2727
}),
2828
[observer, rerender]
2929
)

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: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,79 @@
11
import React from 'react'
22

3-
import { uid, isServer } from '../core/utils'
4-
5-
export function useUid(): number {
6-
const ref = React.useRef(0)
7-
8-
if (ref.current === null) {
9-
ref.current = uid()
10-
}
11-
12-
return ref.current
13-
}
3+
import { isServer } from '../core/utils'
144

155
export function useGetLatest<T>(obj: T): () => T {
166
const ref = React.useRef<T>(obj)
177
ref.current = obj
188
return React.useCallback(() => ref.current, [])
199
}
2010

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

2415
React[isServer ? 'useEffect' : 'useLayoutEffect'](() => {
25-
mounted.current = true
16+
mountedRef.current = true
2617
return () => {
27-
mounted.current = false
18+
mountedRef.current = false
2819
}
2920
}, [])
3021

22+
return isMounted
23+
}
24+
25+
export function useMountedCallback<T extends Function>(callback: T): T {
26+
const isMounted = useIsMounted()
3127
return (React.useCallback(
32-
(...args: any[]) => (mounted.current ? callback(...args) : void 0),
33-
[callback]
28+
(...args: any[]) => {
29+
if (isMounted()) {
30+
return callback(...args)
31+
}
32+
},
33+
[callback, isMounted]
3434
) as any) as T
3535
}
3636

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

0 commit comments

Comments
 (0)