Skip to content

Commit f85338b

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

File tree

5 files changed

+127
-44
lines changed

5 files changed

+127
-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: 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/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 different 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 schedule state updates after 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)