Skip to content

Commit ad8873b

Browse files
committed
refactor: move prevent set state in render logic to useIsFetching hook
1 parent 75a1d3d commit ad8873b

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 { defaultConfigRef, ReactQueryConfigRef } from './config'
1011
import { Query } from './query'
@@ -258,17 +259,7 @@ export class QueryCache {
258259

259260
if (!this.config.frozen) {
260261
this.queries[queryHash] = query
261-
262-
if (isServer) {
263-
this.notifyGlobalListeners()
264-
} else {
265-
// Here, we setTimeout so as to not trigger
266-
// any setState's in parent components in the
267-
// middle of the render phase.
268-
setTimeout(() => {
269-
this.notifyGlobalListeners()
270-
})
271-
}
262+
this.notifyGlobalListeners(query)
272263
}
273264
}
274265

@@ -378,13 +369,18 @@ export class QueryCache {
378369
updater: Updater<TResult | undefined, TResult>,
379370
config?: QueryConfig<TResult, TError>
380371
) {
381-
let query = this.getQuery<TResult, TError>(queryKey)
372+
const query = this.getQuery<TResult, TError>(queryKey)
382373

383-
if (!query) {
384-
query = this.buildQuery<TResult, TError>(queryKey, config)
374+
if (query) {
375+
query.setData(updater)
376+
return
385377
}
386378

387-
query.setData(updater)
379+
this.buildQuery<TResult, TError>(queryKey, {
380+
initialStale: typeof config?.staleTime === 'undefined',
381+
initialData: functionalUpdate(updater, undefined),
382+
...config,
383+
})
388384
}
389385
}
390386

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
@@ -20,7 +20,7 @@ export function useBaseQuery<TResult, TError>(
2020
React.useEffect(
2121
() =>
2222
observer.subscribe(() => {
23-
Promise.resolve().then(rerender)
23+
rerender()
2424
}),
2525
[observer, rerender]
2626
)

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)