Skip to content

Commit

Permalink
feat(core): memoize result of combine (TanStack#7233)
Browse files Browse the repository at this point in the history
* feat(core): memoize result of combine

this PR makes sure to only re-run the combine function if the function itself changed referentially or any of the inputs changed

* docs: combine memoization

* Update docs/framework/react/reference/useQueries.md
  • Loading branch information
TkDodo authored Apr 5, 2024
1 parent 4d37cfc commit 3c31124
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 2 deletions.
9 changes: 9 additions & 0 deletions docs/framework/react/reference/useQueries.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,12 @@ const combinedQueries = useQueries({
```

In the above example, `combinedQueries` will be an object with a `data` and a `pending` property. Note that all other properties of the Query results will be lost.

### Memoization

The `combine` function will only re-run if:

- the `combine` function itself changed referentially
- any of the query results changed

This means that an inlined `combine` function, as shown above, will run on every render. To avoid this, you can wrap the `combine` function in `useCallback`, or extract it so a stable function reference if it doesn't have any dependencies.
17 changes: 16 additions & 1 deletion packages/query-core/src/queriesObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export class QueriesObserver<
#queries: Array<QueryObserverOptions>
#observers: Array<QueryObserver>
#combinedResult?: TCombinedResult
#lastCombine?: CombineFn<TCombinedResult>
#lastResult?: Array<QueryObserverResult>

constructor(
client: QueryClient,
Expand Down Expand Up @@ -181,7 +183,20 @@ export class QueriesObserver<
combine: CombineFn<TCombinedResult> | undefined,
): TCombinedResult {
if (combine) {
return replaceEqualDeep(this.#combinedResult, combine(input))
if (
!this.#combinedResult ||
this.#result !== this.#lastResult ||
combine !== this.#lastCombine
) {
this.#lastCombine = combine
this.#lastResult = this.#result
this.#combinedResult = replaceEqualDeep(
this.#combinedResult,
combine(input),
)
}

return this.#combinedResult
}
return input as any
}
Expand Down
154 changes: 153 additions & 1 deletion packages/react-query/src/__tests__/useQueries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { fireEvent, render, waitFor } from '@testing-library/react'
import * as React from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { QueryClient } from '@tanstack/query-core'
import { QueryCache, queryOptions, skipToken, useQueries } from '..'
import { createQueryClient, queryKey, renderWithClient, sleep } from './utils'
import type {
QueryFunction,
QueryKey,
QueryObserverResult,
UseQueryOptions,
UseQueryResult,
} from '..'
Expand Down Expand Up @@ -975,7 +977,7 @@ describe('useQueries', () => {
)
})

it.skip('should not return new instances when called without queries', async () => {
it('should not return new instances when called without queries', async () => {
const key = queryKey()
const ids: Array<number> = []
let resultChanged = 0
Expand Down Expand Up @@ -1278,4 +1280,154 @@ describe('useQueries', () => {

await waitFor(() => rendered.getByText('data: 1 result'))
})

it('should optimize combine if it is a stable reference', async () => {
const key1 = queryKey()
const key2 = queryKey()

const client = new QueryClient()

const spy = vi.fn()
let value = 0

function Page() {
const [state, setState] = React.useState(0)
const queries = useQueries(
{
queries: [
{
queryKey: key1,
queryFn: async () => {
await sleep(10)
return 'first result:' + value
},
},
{
queryKey: key2,
queryFn: async () => {
await sleep(20)
return 'second result:' + value
},
},
],
combine: React.useCallback((results: Array<QueryObserverResult>) => {
const result = {
combined: true,
res: results.map((res) => res.data).join(','),
}
spy(result)
return result
}, []),
},
client,
)

return (
<div>
<div>
data: {String(queries.combined)} {queries.res}
</div>
<button onClick={() => setState(state + 1)}>rerender</button>
</div>
)
}

const rendered = render(<Page />)

await waitFor(() =>
rendered.getByText('data: true first result:0,second result:0'),
)

// both pending, one pending, both resolved
expect(spy).toHaveBeenCalledTimes(3)

await client.refetchQueries()
// no increase because result hasn't changed
expect(spy).toHaveBeenCalledTimes(3)

fireEvent.click(rendered.getByRole('button', { name: /rerender/i }))

// no increase because just a re-render
expect(spy).toHaveBeenCalledTimes(3)

value = 1

await client.refetchQueries()

await waitFor(() =>
rendered.getByText('data: true first result:1,second result:1'),
)

// two value changes = two re-renders
expect(spy).toHaveBeenCalledTimes(5)
})

it('should re-run combine if the functional reference changes', async () => {
const key1 = queryKey()
const key2 = queryKey()

const client = new QueryClient()

const spy = vi.fn()

function Page() {
const [state, setState] = React.useState(0)
const queries = useQueries(
{
queries: [
{
queryKey: [key1],
queryFn: async () => {
await sleep(10)
return 'first result'
},
},
{
queryKey: [key2],
queryFn: async () => {
await sleep(20)
return 'second result'
},
},
],
combine: React.useCallback(
(results: Array<QueryObserverResult>) => {
const result = {
combined: true,
state,
res: results.map((res) => res.data).join(','),
}
spy(result)
return result
},
[state],
),
},
client,
)

return (
<div>
<div>
data: {String(queries.state)} {queries.res}
</div>
<button onClick={() => setState(state + 1)}>rerender</button>
</div>
)
}

const rendered = render(<Page />)

await waitFor(() =>
rendered.getByText('data: 0 first result,second result'),
)

// both pending, one pending, both resolved
expect(spy).toHaveBeenCalledTimes(3)

fireEvent.click(rendered.getByRole('button', { name: /rerender/i }))

// state changed, re-run combine
expect(spy).toHaveBeenCalledTimes(4)
})
})

0 comments on commit 3c31124

Please sign in to comment.