Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Feature/use tracked query #1578

Merged
merged 20 commits into from
Jan 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/pages/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Feature/Capability Key:

> **<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.

> **<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']`.
> **<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.

> **<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.

Expand Down
5 changes: 3 additions & 2 deletions docs/src/pages/reference/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const {
refetchOnWindowFocus,
retry,
retryDelay,
select
select,
staleTime,
structuralSharing,
suspense,
Expand Down Expand Up @@ -109,10 +109,11 @@ const result = useQuery({
- If set to `true`, the query will refetch on reconnect if the data is stale.
- If set to `false`, the query will not refetch on reconnect.
- If set to `"always"`, the query will always refetch on reconnect.
- `notifyOnChangeProps: string[]`
- `notifyOnChangeProps: string[] | "tracked"`
- Optional
- If set, the component will only re-render if any of the listed properties change.
- If set to `['data', 'error']` for example, the component will only re-render when the `data` or `error` properties change.
- If set to `"tracked"`, access to properties will be tracked, and the component will only re-render when one of the tracked properties change.
- `notifyOnChangePropsExclusions: string[]`
- Optional
- If set, the component will not re-render if any of the listed properties change.
Expand Down
39 changes: 37 additions & 2 deletions src/core/queryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export class QueryObserver<
private initialErrorUpdateCount: number
private staleTimeoutId?: number
private refetchIntervalId?: number
private trackedProps!: Array<keyof QueryObserverResult>
private trackedCurrentResult!: QueryObserverResult<TData, TError>

constructor(
client: QueryClient,
Expand All @@ -65,6 +67,7 @@ export class QueryObserver<
this.options = options
this.initialDataUpdateCount = 0
this.initialErrorUpdateCount = 0
this.trackedProps = []
this.bindMethods()
this.setOptions(options)
}
Expand Down Expand Up @@ -208,6 +211,10 @@ export class QueryObserver<
return this.currentResult
}

getTrackedCurrentResult(): QueryObserverResult<TData, TError> {
return this.trackedCurrentResult
}

getNextResult(
options?: ResultOptions
): Promise<QueryObserverResult<TData, TError>> {
Expand Down Expand Up @@ -449,19 +456,27 @@ export class QueryObserver<
}

const keys = Object.keys(result)
const includedProps =
notifyOnChangeProps === 'tracked'
? this.trackedProps
: notifyOnChangeProps

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

if (changed) {
if (notifyOnChangePropsExclusions && isExcluded) {
continue
}

if (!notifyOnChangeProps || isIncluded) {
if (
!notifyOnChangeProps ||
isIncluded ||
(notifyOnChangeProps === 'tracked' && this.trackedProps.length === 0)
) {
return true
}
}
Expand All @@ -479,6 +494,26 @@ export class QueryObserver<
// Only update if something has changed
if (!shallowEqualObjects(result, this.currentResult)) {
this.currentResult = result

if (this.options.notifyOnChangeProps === 'tracked') {
const addTrackedProps = (prop: keyof QueryObserverResult) => {
if (!this.trackedProps.includes(prop)) {
this.trackedProps.push(prop)
}
}
this.trackedCurrentResult = {} as QueryObserverResult<TData, TError>

Object.keys(result).forEach(key => {
Object.defineProperty(this.trackedCurrentResult, key, {
configurable: false,
enumerable: true,
get() {
addTrackedProps(key as keyof QueryObserverResult)
return result[key as keyof QueryObserverResult]
},
})
})
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,9 @@ export interface QueryObserverOptions<
/**
* If set, the component will only re-render if any of the listed properties change.
* When set to `['data', 'error']`, the component will only re-render when the `data` or `error` properties change.
* When set to `tracked`, access to properties will be tracked, and the component will only re-render when one of the tracked properties change.
*/
notifyOnChangeProps?: Array<keyof InfiniteQueryObserverResult>
notifyOnChangeProps?: Array<keyof InfiniteQueryObserverResult> | 'tracked'
/**
* If set, the component will not re-render if any of the listed properties change.
*/
Expand Down
140 changes: 140 additions & 0 deletions src/react/tests/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,146 @@ describe('useQuery', () => {
expect(states[1].dataUpdatedAt).not.toBe(states[2].dataUpdatedAt)
})

it('should track properties and only re-render when a tracked property changes', async () => {
boschni marked this conversation as resolved.
Show resolved Hide resolved
const key = queryKey()
const states: UseQueryResult<string>[] = []

function Page() {
const state = useQuery(key, () => 'test', {
notifyOnChangeProps: 'tracked',
})

states.push(state)

const { refetch, data } = state

React.useEffect(() => {
if (data) {
refetch()
}
}, [refetch, data])

return (
<div>
<h1>{data ?? null}</h1>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)

await waitFor(() => rendered.getByText('test'))

expect(states.length).toBe(2)
expect(states[0]).toMatchObject({ data: undefined })
expect(states[1]).toMatchObject({ data: 'test' })
})

it('should not re-render if a tracked prop changes, but it was excluded', async () => {
const key = queryKey()
const states: UseQueryResult<string>[] = []

function Page() {
const state = useQuery(key, () => 'test', {
notifyOnChangeProps: 'tracked',
notifyOnChangePropsExclusions: ['data'],
})

states.push(state)

return (
<div>
<h1>{state.data ?? 'null'}</h1>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)

await waitFor(() => rendered.getByText('null'))
expect(states.length).toBe(1)
expect(states[0]).toMatchObject({ data: undefined })

await queryClient.refetchQueries(key)
await waitFor(() => rendered.getByText('null'))
expect(states.length).toBe(1)
expect(states[0]).toMatchObject({ data: undefined })
})

it('should return the referentially same object if nothing changes between fetches', async () => {
const key = queryKey()
let renderCount = 0
const states: UseQueryResult<string>[] = []

function Page() {
const state = useQuery(key, () => 'test', {
notifyOnChangeProps: 'tracked',
})

states.push(state)

const { data } = state

React.useEffect(() => {
renderCount++
}, [state])

return (
<div>
<h1>{data ?? null}</h1>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)

await waitFor(() => rendered.getByText('test'))
expect(renderCount).toBe(2)
expect(states.length).toBe(2)
expect(states[0]).toMatchObject({ data: undefined })
expect(states[1]).toMatchObject({ data: 'test' })

act(() => rendered.rerender(<Page />))
await waitFor(() => rendered.getByText('test'))
expect(renderCount).toBe(2)
expect(states.length).toBe(3)
expect(states[0]).toMatchObject({ data: undefined })
expect(states[1]).toMatchObject({ data: 'test' })
expect(states[2]).toMatchObject({ data: 'test' })
})

it('should always re-render if we are tracking props but not using any', async () => {
const key = queryKey()
let renderCount = 0
const states: UseQueryResult<string>[] = []

function Page() {
const state = useQuery(key, () => 'test', {
notifyOnChangeProps: 'tracked',
})

states.push(state)

React.useEffect(() => {
renderCount++
}, [state])

return (
<div>
<h1>hello</h1>
</div>
)
}

renderWithClient(queryClient, <Page />)

await waitFor(() => renderCount > 1)
expect(renderCount).toBe(2)
expect(states.length).toBe(2)
expect(states[0]).toMatchObject({ data: undefined })
expect(states[1]).toMatchObject({ data: 'test' })
})

it('should be able to remove a query', async () => {
const key = queryKey()
const states: UseQueryResult<number>[] = []
Expand Down
4 changes: 3 additions & 1 deletion src/react/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,7 @@ export function useBaseQuery<TQueryFnData, TError, TData, TQueryData>(
}
}

return currentResult
return observer.options.notifyOnChangeProps === 'tracked'
? observer.getTrackedCurrentResult()
: currentResult
}