Skip to content

Instantly share code, notes, and snippets.

@patik
Last active June 28, 2022 10:14
Show Gist options
  • Save patik/20ecf4563ac1f8c043d985298549f60c to your computer and use it in GitHub Desktop.
Save patik/20ecf4563ac1f8c043d985298549f60c to your computer and use it in GitHub Desktop.
Combine multiple useInfiniteQuery results from react-query

Merge useInfiniteQuery Results

This utility allows you to make multiple requests with react-query's useInfiniteQuery hook and then combine all of them into a single result. That is, you can fetch data from multiple endpoints, but you'll only need to check one isSuccess status, call only one refetch function, etc.

Allows for sorting and de-duping data, if needed.

Example

const foo = useInfiniteQuery('/api/foo')
const bar = useInfiniteQuery('/api/bar')

const combined = mergeInfiniteQueryResults([foo, bar])

// Check the status of all requests:
combined.status

// Fetch the next page of results for all endpoints:
combined.fetchNextPage()
import { UseInfiniteQueryResult } from 'react-query'
import { combineQueryResultStatuses, mergeInfiniteQueryResults } from './mergeInfiniteQueryResults'
// Define or import your types:
type Entity = { ... }
type QueryKey = '...' // or use the generic one: `import { QueryKey } from 'react-query'`
// Define or import your own dummy data:
const infiniteQueryResult: UseInfiniteQueryResult<Entity, QueryKey> = { /* ... */ }
const entities: Entity[] = [ /* ... */ ]
// End of customizations
function isQueryResultProp<T, U>(propName: string): propName is keyof UseInfiniteQueryResult<T, U> {
return Object.prototype.hasOwnProperty.call(infiniteQueryResult, propName)
}
describe('combining react-query results', () => {
describe('combineQueryResultStatuses', () => {
test('when one of them has an error', () => {
const alpha: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
status: 'error',
}
const bravo: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
status: 'loading',
}
const charlie: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
status: 'idle',
}
const delta: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
status: 'success',
}
expect(combineQueryResultStatuses([alpha, bravo, charlie, delta])).toBe('error')
})
test('when one of them is loading and none have errors', () => {
const bravo: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
status: 'loading',
}
const charlie: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
status: 'idle',
}
const delta: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
status: 'success',
}
expect(combineQueryResultStatuses([bravo, charlie, delta])).toBe('loading')
})
test('when one of them is successful and none are loading or have errors', () => {
const charlie: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
status: 'idle',
}
const delta: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
status: 'success',
}
expect(combineQueryResultStatuses([charlie, delta])).toBe('success')
})
test('when all are idle', () => {
const charlie: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
status: 'idle',
}
const delta: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
status: 'idle',
}
expect(combineQueryResultStatuses([charlie, delta])).toBe('idle')
})
})
describe('mergeInfiniteQueryResults', () => {
test('combined data lists are sorted and de-duped', () => {
const alpha = entities[0]
const bravo = entities[1]
const charlie = entities[0]
const delta = entities[1]
// Insert the items out of order so we can check that they're sorted later
const test1: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
data: [delta, alpha],
}
const test2: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
// Note that `alpha` appears in both sets
data: [alpha, bravo, charlie],
}
// Verify test setup
expect(test1.data.concat(test2.data).length).toBe(5)
// Run test
const merged = mergeInfiniteQueryResults([test1, test2], (a, b) => (a.lastChanged > b.lastChanged ? -1 : 1))
expect(merged.data.length).toBe(4)
expect(merged.data[0].id).toBe(alpha.id)
expect(merged.data[1].id).toBe(bravo.id)
expect(merged.data[2].id).toBe(charlie.id)
expect(merged.data[3].id).toBe(delta.id)
})
describe('boolean props', () => {
test.each(['isSuccess', 'isFetched', 'isFetchedAfterMount', 'isIdle'])(
'prop that is only true when ALL results have it set to true: %p',
(propName) => {
if (!isQueryResultProp(propName)) {
throw new Error(`Property name is not valid for a UseInfiniteQuery result: ${propName}`)
}
const isTrue1: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
[propName]: true,
}
const isTrue2: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
[propName]: true,
}
const isFalse1: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
[propName]: false,
}
const isFalse2: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
[propName]: false,
}
expect(mergeInfiniteQueryResults([isTrue1])[propName]).toBe(true)
expect(mergeInfiniteQueryResults([isTrue2])[propName]).toBe(true)
expect(mergeInfiniteQueryResults([isFalse1])[propName]).toBe(false)
expect(mergeInfiniteQueryResults([isFalse2])[propName]).toBe(false)
expect(mergeInfiniteQueryResults([isTrue1, isTrue2])[propName]).toBe(true)
expect(mergeInfiniteQueryResults([isTrue1, isFalse1])[propName]).toBe(false)
expect(mergeInfiniteQueryResults([isFalse1, isTrue1])[propName]).toBe(false)
expect(mergeInfiniteQueryResults([isFalse1, isFalse2])[propName]).toBe(false)
}
)
test.each([
'isLoading',
'hasNextPage',
'hasPreviousPage',
'isFetching',
'isFetchingNextPage',
'isFetchingPreviousPage',
'isError',
'isLoadingError',
'isPreviousData',
'isRefetchError',
'isRefetching',
'isStale',
'isPlaceholderData',
])('prop that can be true if ANY of the results have it as true: %p', (propName) => {
if (!isQueryResultProp(propName)) {
throw new Error(`Property name is not valid for a UseInfiniteQuery result: ${propName}`)
}
const isTrue1: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
[propName]: true,
}
const isTrue2: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
[propName]: true,
}
const isFalse1: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
[propName]: false,
}
const isFalse2: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
[propName]: false,
}
expect(mergeInfiniteQueryResults([isTrue1])[propName]).toBe(true)
expect(mergeInfiniteQueryResults([isTrue2])[propName]).toBe(true)
expect(mergeInfiniteQueryResults([isFalse1])[propName]).toBe(false)
expect(mergeInfiniteQueryResults([isFalse2])[propName]).toBe(false)
expect(mergeInfiniteQueryResults([isTrue1, isTrue2])[propName]).toBe(true)
expect(mergeInfiniteQueryResults([isTrue1, isFalse1])[propName]).toBe(true)
expect(mergeInfiniteQueryResults([isFalse1, isTrue1])[propName]).toBe(true)
expect(mergeInfiniteQueryResults([isFalse1, isFalse2])[propName]).toBe(false)
})
})
describe('numeric values', () => {
test.each(['failureCount', 'serverTotal', 'errorUpdateCount'])('sums the values for: %p', (propName) => {
if (!isQueryResultProp(propName)) {
throw new Error(`Property name is not valid for a UseInfiniteQuery result: ${propName}`)
}
const alpha: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
[propName]: 1,
}
const bravo: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
[propName]: 7,
}
expect(mergeInfiniteQueryResults([alpha])[propName]).toBe(1)
expect(mergeInfiniteQueryResults([bravo])[propName]).toBe(7)
expect(mergeInfiniteQueryResults([alpha, bravo])[propName]).toBe(8)
})
})
describe('fetching methods', () => {
test.each(['fetchNextPage', 'fetchPreviousPage', 'refetch', 'remove'])(
'calls the %p method from each result',
(propName) => {
if (!isQueryResultProp(propName)) {
throw new Error(`Property name is not valid for a UseInfiniteQuery result: ${propName}`)
}
const alphaMock = jest.fn()
const bravoMock = jest.fn()
const alpha: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
[propName]: () =>
new Promise(() => {
alphaMock()
}),
}
const bravo: UseInfiniteQueryResult<Entity, QueryKey> = {
...infiniteQueryResult,
[propName]: () =>
new Promise(() => {
bravoMock()
}),
}
const merged = mergeInfiniteQueryResults([alpha, bravo])
// Tell TS that this is callable
const func = merged[propName]
if (!(func instanceof Function)) {
throw new Error(`Property is not callable: ${propName}`)
}
func()
expect(alphaMock).toBeCalledTimes(1)
expect(bravoMock).toBeCalledTimes(1)
}
)
})
})
})
import { UseInfiniteQueryResult } from 'react-query'
import { uniqBy } from 'lodash-es'
// Define or import your types:
type Entity = { ... } // This is the data type for a single entity being fetched by useInfiniteQuery
type QueryKey = '...' // or use the generic one: `import { QueryKey } from 'react-query'`
/**
* Combines multiple useInfiniteQuery results into a single result
*
* Fetching methods are combined such that each result's copy of the method will be called, e.g. so that fetching happens for each individual query
*
* Boolean properties account for whether we want the value to be true for ALL results (e.g. isSuccess is not true unless it's true for all of them), and other times we only need one of them to be true (e.g. if one result has `isError`, then the whole set of results is considered as having an error).
*
* @export
* @template Entity
* @template QueryKey
* @param {Array<UseInfiniteQueryResult<Entity, QueryKey>>} results
* @param {(entry: Entity) => unknown} [removeDuplicates=(entry) => entry.id]
* @param {(a: Entity, b: Entity) => number} [sortData=() => 0]
* @return {*} {UseInfiniteQueryResult<T, U>}
*/
export function mergeInfiniteQueryResults<Entity extends { id: string | number }, QueryKey>(
results: Array<UseInfiniteQueryResult<Entity, QueryKey>>,
removeDuplicates: (entry: Entity) => unknown = (entry) => entry.id,
sortData: (a: Entity, b: Entity) => number = () => 0
): UseInfiniteQueryResult<Entity, QueryKey> {
const consolidatedResult = { ...results[0] }
// Combine all items into a single array that is de-duped and sorted
const data = uniqBy(
// Combine all entries into a flattened array
results.flatMap((result) => result.data),
// De-duplicate the entries based on their IDs
removeDuplicates
).sort(sortData)
return {
...consolidatedResult,
data,
// Boolean properties which we consider to be true only if ALL of the results are true
isIdle: results.every((result) => Boolean(result.isIdle)),
isFetched: results.every((result) => Boolean(result.isFetched)),
isFetchedAfterMount: results.every((result) => Boolean(result.isFetchedAfterMount)),
isSuccess: results.every((result) => Boolean(result.isSuccess)),
// Boolean properties which we consider to be true if ANY of the results are true
hasNextPage: results.some((result) => Boolean(result.hasNextPage)),
hasPreviousPage: results.some((result) => Boolean(result.hasPreviousPage)),
isError: results.some((result) => Boolean(result.isError)),
isFetching: results.some((result) => Boolean(result.isFetching)),
isFetchingNextPage: results.some((result) => Boolean(result.isFetchingNextPage)),
isFetchingPreviousPage: results.some((result) => Boolean(result.isFetchingPreviousPage)),
isLoading: results.some((result) => Boolean(result.isLoading)),
isLoadingError: results.some((result) => Boolean(result.isLoadingError)),
isRefetchError: results.some((result) => Boolean(result.isRefetchError)),
isRefetching: results.some((result) => Boolean(result.isRefetching)),
isPlaceholderData: results.some((result) => Boolean(result.isPlaceholderData)),
isPreviousData: results.some((result) => Boolean(result.isPreviousData)),
isStale: results.some((result) => Boolean(result.isStale)),
// Status
status: combineQueryResultStatuses(results),
// Errors: return the first one (for lack of a better idea on how to inform the caller about errors)
error:
results
.map((result) => result.error)
.filter(Boolean)
.shift() ?? null,
// Dates: choose the latest one
dataUpdatedAt:
results
.map((result) => result.dataUpdatedAt)
.sort()
.pop() ?? 0,
errorUpdatedAt:
results
.map((result) => result.errorUpdatedAt)
.sort()
.pop() ?? 0,
// Numbers: find the sum
failureCount: results.map((result) => result.failureCount).reduce((sum, a) => sum + a, 0),
errorUpdateCount: results.map((result) => result.errorUpdateCount).reduce((sum, a) => sum + a, 0),
fetchNextPage: () =>
new Promise(() => {
results.forEach((result) => {
if (!result.isFetchingNextPage) {
result.fetchNextPage()
}
})
}),
fetchPreviousPage: () =>
new Promise(() => {
results.forEach((result) => {
if (!result.isFetchingPreviousPage) {
result.fetchPreviousPage()
}
})
}),
refetch: () =>
new Promise(() => {
results.forEach((result) => {
if (!result.isRefetching) {
result.refetch()
}
})
}),
remove: () =>
new Promise(() => {
results.forEach((result) => {
result.remove()
})
}),
}
}
export function combineQueryResultStatuses<Entity, QueryKey>(
results: Array<UseInfiniteQueryResult<Entity, QueryKey>>
): UseInfiniteQueryResult<Entity, QueryKey>['status'] {
if (results.some((result) => result.status === 'error')) {
return 'error'
}
if (results.some((result) => result.status === 'loading')) {
return 'loading'
}
if (results.some((result) => result.status === 'success')) {
return 'success'
}
return 'idle'
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment