useQueries with duplicate query keys returns wrong value from select when placeholderData is not an object
Describe the bug
When passing two queries with the same query key to useQueries, with a select function and a non-object value as placeholder data, the data of both entries in the query results array will be the value of the first call to select.
const results = useQueries({
queries: [
{ queryKey: ["abc"], queryFn: () => fetchSomething(), placeholderData: null, select: data => ({ id: 1, data }) },
{ queryKey: ["abc"], queryFn: () => fetchSomething(), placeholderData: null, select: data => ({ id: 2, data }) },
]
})
// prints { id: 1, data: null }
console.log(results[0].data)
// prints { id: 1, data: null }, but id should be 2
console.log(results[1].data)
This causes a "duplicate key" warning in React, because the id is the same for two items.
Once the fetch is completed the data is correct. It also works if you provide an object value (e.g. {}).
Your minimal, reproducible example
https://codesandbox.io/s/gracious-solomon-pvbv9m
Steps to reproduce
- Run the following code in a component.
const results = useQueries({
queries: [
{
queryKey: ["abc"],
queryFn: () => new Promise(() => {}),
placeholderData: null,
select: (data) => ({ id: 1, data }),
enabled: false
},
{
queryKey: ["abc"],
queryFn: () => new Promise(() => {}),
placeholderData: null,
select: (data) => ({ id: 2, data }),
enabled: false
}
]
});
console.log(results[0].data)
console.log(results[1].data)
- Check the output in the console.
Expected behavior
As a user, I expected the data returned for each query to be mapped using the select-function.
How often does this bug happen?
Every time
Screenshots or Videos
No response
Platform
- OS: macOS
- Browser: Firefox Developer Edition
- Version: 105.0b9
react-query version
v4.3.9
TypeScript version
v4.1.3
Additional context
In our app, we are fetching time series data for some charts based on user configuration. The configuration consists of some UI-stuff (e.g. name and color of the time series) and a query used to fetch the time series. It looks roughly like this:
interface ChartConfig {
series: TimeSeriesConfig[]
}
interface TimeSeriesConfig {
label: string
color: string
query: { metric: string, method: string }
}
The user has complete freedom to configure their charts, so it is possible for them to include the exact same query in both other charts and in the same chart. The labels and colors however might vary, so we only want data to be cached on the actual query.
We use useQueries to fetch the data and then merge the config with the time series data using select:
const results = useQueries({
queries: configs.map(config => ({
queryKey: ['time-series', config.query],
queryFn: () => fetchTimeSeries(config.query),
placeholderData: null,
select: data => ({
...config,
data
})
})
})
In some cases, there might not be any time series data available which we signal by returning null from fetchTimeSeries. To allow some things to render before the time series data is fetched, we set placeholderData to null.
Workaround
Since it works correctly when placeholderData is an object, one workaround is to wrap the result in an discriminated union and provide an empty case as placeholderData:
const results = useQueries({
queries: configs.map(config => ({
queryKey: ['time-series', config.query],
queryFn: () => fetchTimeSeries(config.query).then(data => ({ fetched: true, data }),
placeholderData: {Â fetched: false },
select: result => ({
...config,
data: result.fetched ? result.data : null
})
})
})
This is such an interesting issue, thanks for reporting.
placeholderData runs through select, so I can only assume that referential stability makes it so that we re-use some cached "selected" data that we shouldn't re-use. If placeholderData is "1" , it's wrong, but if it's a new object ({ foo: "1" }), it works.
interestingly, it also works if you do:
placeholderData: () => null,
🤯
okay, the problem must be somewhere in useQueries. As I can see in the devtools, there is only one observer - which means useQueries re-uses observers based on their keys. I think it's just unexpected that we'd have the same key twice within one useQueries call 🤔
You will see two observers, and things will work fine, if we do:
const result1 = useQuery({
queryKey: ["abc"],
queryFn: () => mockFetch("abc", 1),
placeholderData: null,
select: (data) => ({ id: 1, data }),
enabled: shouldFetch
});
const result2 = useQuery({
queryKey: ["abc"],
queryFn: () => mockFetch("abc", 2),
placeholderData: null,
select: (data) => ({ id: 2, data }),
enabled: shouldFetch
});
yep, observers are matched by queryhash:
https://github.com/TanStack/query/blob/357ec041a6fcc4a550f3df02c12ecc7bcdefbc05/packages/query-core/src/queriesObserver.ts#L133
I think this is necessary to make keepPreviousData work and variable length arrays work correctly. I'm not really sure what to do here tbh. Maybe you could split it into multiple useQueries calls to ensure that there is only one call per query key?
This is such an interesting issue, thanks for reporting.
Yes, it certainly is interesting. It was damn near impossible to come up with a name for the github issue, because it so specific. 😅
Maybe you could split it into multiple useQueries calls to ensure that there is only one call per query key?
I'm not sure how I'd do that without breaking the "Thou shalt not use hooks in a for-loop" commandment. I could filter any duplicates and then, instead of using select, try to pair the fetched data with the right config in a useMemo. But it's such a shame, since select is there and makes everything a lot simpler.
It's not really a huge issue for us. The workaround I posted seems to work fine. Besides, there's really no point for users to have two time series with the same query in the same chart, because their plots would be identical. We allow it because it's more convenient if the user can have duplicates while configuring their charts.
Though I am curious as to why it would work when placeholderData is an object or if you use a function? I also tried initialData and that does not have the same problem.
Though I am curious as to why it would work when placeholderData is an object or if you use a function?
because we triple equals compare placeholerData with the one from the previous execution and re-use it if it's equal. Two objects or two functions are never equal, but two nulls are. Maybe we should just remove this "optimization"...
I'm not sure how I'd do that without breaking the "Thou shalt not use hooks in a for-loop" commandment.
I was more thinking of splitting it into two arrays, then call useQueries for both arrays. But yeah, if there could be three equivalent usages as well, you'd need variable useQueries calls 🙈
I think the least we can do is document that useQueries doesn't work well if it contains duplicate keys because observers are re-used and that can lead to shared data between observers like select or placeholderData that are observer specific.
Would you like to contribute that to the docs?
I think the least we can do is document that
useQueriesdoesn't work well if it contains duplicate keys because observers are re-used and that can lead to shared data between observers likeselectorplaceholderDatathat are observer specific.
Yeah, that’s a good idea. Should we document the workaround with using a function/object as well?
Would you like to contribute that to the docs?
I will try to get around to doing it this week.