Skip to content

Commit 39691a7

Browse files
authored
feat: use tracked query (#1578)
* feat: useTrackedQuery add useTrackedQuery hook, which tracks notifyOnChangeProps automatically via a Proxy * feat: notifyOnChangeTracked different approach to tracking used props by using an option rather than a new hook, so that we can also easily combine this with useInfiniteQuery; also, using Object.defineProperty rather than a Proxy * feat: notifyOnChangeTracked add some documentation for notifyOnChangeTracked * feat: notifyOnChangeTracked add a test for notifyOnChangeTracked * feat: notifyOnChangeTracked keep trackedProps separate from notifyOnChangeProps and only merge them in shouldNotifyListeners * feat: notifyOnChangeTracked store tracked result object on queryObserver instance so that the reference stays the same between renders * feat: notifyOnChangeTracked create a new trackedResult whenever we create a new result this ensures that we keep referential identity of the tracked result object if nothing changes, but still get a new instance if we need to (to avoid stale closure problems) * feat: notifyOnChangeTracked add test for referential stability between re-renders; even if we are tracking changes, we should return the same object if we re-render (for some other reason than react-query changes) and nothing has changed * feat: notifyOnChangeTracked rename trackedResult to trackedCurrentResult * feat: notifyOnChangeTracked combine notifyOnChangeTracked with notifyOnChangeProps * feat: notifyOnChangeTracked remove auto-resetting of tracked props, which means that every prop ever tracked will lead to a re-render; this is the "safe" side - we'd rather have an unnecessary re-render rather than a bug because we are not re-rendering when something is observed only in an effect * feat: notifyOnChangeTracked always re-render if we are tracking props, but not using any. In that case, trackedProps will be an empty array, which would lead to no re-renders at all (same as setting notifyOnChangeProps to empty Array) * feat: notifyOnChangeTracked conditionally create trackedResult - we don't need it if we are not actually tracking props * feat: notifyOnChangeTracked add a test for combining notifyOnChangeProps: 'tracked' and notifyOnChangePropsExclusion * feat: notifyOnChangeTracked update docs
1 parent ead2e5d commit 39691a7

File tree

6 files changed

+186
-7
lines changed

6 files changed

+186
-7
lines changed

docs/src/pages/comparison.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Feature/Capability Key:
6363

6464
> **<sup>1</sup> Lagged Query Data** - React Query provides a way to continue to see an existing query's data while the next query loads (similar to the same UX that suspense will soon provide natively). This is extremely important when writing pagination UIs or infinite loading UIs where you do not want to show a hard loading state whenever a new query is requested. Other libraries do not have this capability and render a hard loading state for the new query (unless it has been prefetched), while the new query loads.
6565
66-
> **<sup>2</sup> Render Optimization** - React Query has excellent rendering performance. It will only re-render your components when a query is updated. For example because it has new data, or to indicate it is fetching. React Query also batches updates together to make sure your application only re-renders once when multiple components are using the same query. If you are only interested in the `data` or `error` properties, you can reduce the number of renders even more by setting `notifyOnChangeProps` to `['data', 'error']`.
66+
> **<sup>2</sup> Render Optimization** - React Query has excellent rendering performance. It will only re-render your components when a query is updated. For example because it has new data, or to indicate it is fetching. React Query also batches updates together to make sure your application only re-renders once when multiple components are using the same query. If you are only interested in the `data` or `error` properties, you can reduce the number of renders even more by setting `notifyOnChangeProps` to `['data', 'error']`. Set `notifyOnChangeProps: 'tracked'` to automatically track which fields are accessed and only re-render if one of them changes.
6767
6868
> **<sup>3</sup> Partial query matching** - Because React Query uses deterministic query key serialization, this allows you to manipulate variable groups of queries without having to know each individual query-key that you want to match, eg. you can refetch every query that starts with `todos` in its key, regardless of variables, or you can target specific queries with (or without) variables or nested properties, and even use a filter function to only match queries that pass your specific conditions.
6969

docs/src/pages/reference/useQuery.md

+3-2
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const {
3939
refetchOnWindowFocus,
4040
retry,
4141
retryDelay,
42-
select
42+
select,
4343
staleTime,
4444
structuralSharing,
4545
suspense,
@@ -109,10 +109,11 @@ const result = useQuery({
109109
- If set to `true`, the query will refetch on reconnect if the data is stale.
110110
- If set to `false`, the query will not refetch on reconnect.
111111
- If set to `"always"`, the query will always refetch on reconnect.
112-
- `notifyOnChangeProps: string[]`
112+
- `notifyOnChangeProps: string[] | "tracked"`
113113
- Optional
114114
- If set, the component will only re-render if any of the listed properties change.
115115
- If set to `['data', 'error']` for example, the component will only re-render when the `data` or `error` properties change.
116+
- If set to `"tracked"`, access to properties will be tracked, and the component will only re-render when one of the tracked properties change.
116117
- `notifyOnChangePropsExclusions: string[]`
117118
- Optional
118119
- If set, the component will not re-render if any of the listed properties change.

src/core/queryObserver.ts

+37-2
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export class QueryObserver<
5454
private initialErrorUpdateCount: number
5555
private staleTimeoutId?: number
5656
private refetchIntervalId?: number
57+
private trackedProps!: Array<keyof QueryObserverResult>
58+
private trackedCurrentResult!: QueryObserverResult<TData, TError>
5759

5860
constructor(
5961
client: QueryClient,
@@ -65,6 +67,7 @@ export class QueryObserver<
6567
this.options = options
6668
this.initialDataUpdateCount = 0
6769
this.initialErrorUpdateCount = 0
70+
this.trackedProps = []
6871
this.bindMethods()
6972
this.setOptions(options)
7073
}
@@ -208,6 +211,10 @@ export class QueryObserver<
208211
return this.currentResult
209212
}
210213

214+
getTrackedCurrentResult(): QueryObserverResult<TData, TError> {
215+
return this.trackedCurrentResult
216+
}
217+
211218
getNextResult(
212219
options?: ResultOptions
213220
): Promise<QueryObserverResult<TData, TError>> {
@@ -449,19 +456,27 @@ export class QueryObserver<
449456
}
450457

451458
const keys = Object.keys(result)
459+
const includedProps =
460+
notifyOnChangeProps === 'tracked'
461+
? this.trackedProps
462+
: notifyOnChangeProps
452463

453464
for (let i = 0; i < keys.length; i++) {
454465
const key = keys[i] as keyof QueryObserverResult
455466
const changed = prevResult[key] !== result[key]
456-
const isIncluded = notifyOnChangeProps?.some(x => x === key)
467+
const isIncluded = includedProps?.some(x => x === key)
457468
const isExcluded = notifyOnChangePropsExclusions?.some(x => x === key)
458469

459470
if (changed) {
460471
if (notifyOnChangePropsExclusions && isExcluded) {
461472
continue
462473
}
463474

464-
if (!notifyOnChangeProps || isIncluded) {
475+
if (
476+
!notifyOnChangeProps ||
477+
isIncluded ||
478+
(notifyOnChangeProps === 'tracked' && this.trackedProps.length === 0)
479+
) {
465480
return true
466481
}
467482
}
@@ -479,6 +494,26 @@ export class QueryObserver<
479494
// Only update if something has changed
480495
if (!shallowEqualObjects(result, this.currentResult)) {
481496
this.currentResult = result
497+
498+
if (this.options.notifyOnChangeProps === 'tracked') {
499+
const addTrackedProps = (prop: keyof QueryObserverResult) => {
500+
if (!this.trackedProps.includes(prop)) {
501+
this.trackedProps.push(prop)
502+
}
503+
}
504+
this.trackedCurrentResult = {} as QueryObserverResult<TData, TError>
505+
506+
Object.keys(result).forEach(key => {
507+
Object.defineProperty(this.trackedCurrentResult, key, {
508+
configurable: false,
509+
enumerable: true,
510+
get() {
511+
addTrackedProps(key as keyof QueryObserverResult)
512+
return result[key as keyof QueryObserverResult]
513+
},
514+
})
515+
})
516+
}
482517
}
483518
}
484519

src/core/types.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,9 @@ export interface QueryObserverOptions<
136136
/**
137137
* If set, the component will only re-render if any of the listed properties change.
138138
* When set to `['data', 'error']`, the component will only re-render when the `data` or `error` properties change.
139+
* When set to `tracked`, access to properties will be tracked, and the component will only re-render when one of the tracked properties change.
139140
*/
140-
notifyOnChangeProps?: Array<keyof InfiniteQueryObserverResult>
141+
notifyOnChangeProps?: Array<keyof InfiniteQueryObserverResult> | 'tracked'
141142
/**
142143
* If set, the component will not re-render if any of the listed properties change.
143144
*/

src/react/tests/useQuery.test.tsx

+140
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,146 @@ describe('useQuery', () => {
739739
expect(states[1].dataUpdatedAt).not.toBe(states[2].dataUpdatedAt)
740740
})
741741

742+
it('should track properties and only re-render when a tracked property changes', async () => {
743+
const key = queryKey()
744+
const states: UseQueryResult<string>[] = []
745+
746+
function Page() {
747+
const state = useQuery(key, () => 'test', {
748+
notifyOnChangeProps: 'tracked',
749+
})
750+
751+
states.push(state)
752+
753+
const { refetch, data } = state
754+
755+
React.useEffect(() => {
756+
if (data) {
757+
refetch()
758+
}
759+
}, [refetch, data])
760+
761+
return (
762+
<div>
763+
<h1>{data ?? null}</h1>
764+
</div>
765+
)
766+
}
767+
768+
const rendered = renderWithClient(queryClient, <Page />)
769+
770+
await waitFor(() => rendered.getByText('test'))
771+
772+
expect(states.length).toBe(2)
773+
expect(states[0]).toMatchObject({ data: undefined })
774+
expect(states[1]).toMatchObject({ data: 'test' })
775+
})
776+
777+
it('should not re-render if a tracked prop changes, but it was excluded', async () => {
778+
const key = queryKey()
779+
const states: UseQueryResult<string>[] = []
780+
781+
function Page() {
782+
const state = useQuery(key, () => 'test', {
783+
notifyOnChangeProps: 'tracked',
784+
notifyOnChangePropsExclusions: ['data'],
785+
})
786+
787+
states.push(state)
788+
789+
return (
790+
<div>
791+
<h1>{state.data ?? 'null'}</h1>
792+
</div>
793+
)
794+
}
795+
796+
const rendered = renderWithClient(queryClient, <Page />)
797+
798+
await waitFor(() => rendered.getByText('null'))
799+
expect(states.length).toBe(1)
800+
expect(states[0]).toMatchObject({ data: undefined })
801+
802+
await queryClient.refetchQueries(key)
803+
await waitFor(() => rendered.getByText('null'))
804+
expect(states.length).toBe(1)
805+
expect(states[0]).toMatchObject({ data: undefined })
806+
})
807+
808+
it('should return the referentially same object if nothing changes between fetches', async () => {
809+
const key = queryKey()
810+
let renderCount = 0
811+
const states: UseQueryResult<string>[] = []
812+
813+
function Page() {
814+
const state = useQuery(key, () => 'test', {
815+
notifyOnChangeProps: 'tracked',
816+
})
817+
818+
states.push(state)
819+
820+
const { data } = state
821+
822+
React.useEffect(() => {
823+
renderCount++
824+
}, [state])
825+
826+
return (
827+
<div>
828+
<h1>{data ?? null}</h1>
829+
</div>
830+
)
831+
}
832+
833+
const rendered = renderWithClient(queryClient, <Page />)
834+
835+
await waitFor(() => rendered.getByText('test'))
836+
expect(renderCount).toBe(2)
837+
expect(states.length).toBe(2)
838+
expect(states[0]).toMatchObject({ data: undefined })
839+
expect(states[1]).toMatchObject({ data: 'test' })
840+
841+
act(() => rendered.rerender(<Page />))
842+
await waitFor(() => rendered.getByText('test'))
843+
expect(renderCount).toBe(2)
844+
expect(states.length).toBe(3)
845+
expect(states[0]).toMatchObject({ data: undefined })
846+
expect(states[1]).toMatchObject({ data: 'test' })
847+
expect(states[2]).toMatchObject({ data: 'test' })
848+
})
849+
850+
it('should always re-render if we are tracking props but not using any', async () => {
851+
const key = queryKey()
852+
let renderCount = 0
853+
const states: UseQueryResult<string>[] = []
854+
855+
function Page() {
856+
const state = useQuery(key, () => 'test', {
857+
notifyOnChangeProps: 'tracked',
858+
})
859+
860+
states.push(state)
861+
862+
React.useEffect(() => {
863+
renderCount++
864+
}, [state])
865+
866+
return (
867+
<div>
868+
<h1>hello</h1>
869+
</div>
870+
)
871+
}
872+
873+
renderWithClient(queryClient, <Page />)
874+
875+
await waitFor(() => renderCount > 1)
876+
expect(renderCount).toBe(2)
877+
expect(states.length).toBe(2)
878+
expect(states[0]).toMatchObject({ data: undefined })
879+
expect(states[1]).toMatchObject({ data: 'test' })
880+
})
881+
742882
it('should be able to remove a query', async () => {
743883
const key = queryKey()
744884
const states: UseQueryResult<number>[] = []

src/react/useBaseQuery.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,7 @@ export function useBaseQuery<TQueryFnData, TError, TData, TQueryData>(
7979
}
8080
}
8181

82-
return currentResult
82+
return observer.options.notifyOnChangeProps === 'tracked'
83+
? observer.getTrackedCurrentResult()
84+
: currentResult
8385
}

0 commit comments

Comments
 (0)