How to Combine TanStack Query Data in React
August 1, 2026 · 13 min read

Who this is for. You already use TanStack Query and are comfortable with
useQueryand query keys. If you are not there yet, read TkDodo’s React Query series first. It is the best writing there is on this library, and everything below assumes it.
One common TanStack Query pattern is that you can create your own wrapper hooks around the primitive hooks the library provides, especially if you want to reuse them across several components or other hooks, or just want to keep your components’ code clean:
// src/components/Asset.tsx
const Asset = ({ assetId }) => {
const { data } = useAssetPrice(assetId)
return (/* ... */)
}
// src/api/queries.ts
const useAssetPrice = (assetId) => useQuery(priceQuery(assetId))
const priceQuery = (assetId) =>
queryOptions({
queryKey: ['prices'],
queryFn: () => fetchPrices(),
select: (prices) => prices[assetId]
})TkDodo recommends exactly this in Practical React Query, and it is good advice. The query key and the fetcher live in one place. The component doesn’t need to know how the data arrives, it just uses the data it needs.
An interesting question then is: What goes inside these wrapper hooks?
For an example like the one above where the data the component needs comes directly from a single API endpoint, the answer is simple: just the useQuery call. But what if the shape of the data the component needs has to be derived by combining data from multiple API endpoints?
To illustrate what I mean: Take the example of a portfolio app that supports multiple accounts and displays the total value. You create usePortfolioValue().
The endpoints you have available for deriving the value are:
// get assets list of 1 account
GET /accounts/:id/assets -> [{ assetId, amount }]
// get prices of all assets from an external market-data service
GET /prices -> { assetId: price }How would you do it?
A detailed catalogue of options for transforming data already exists. Combining is a different problem, and only partially addressed online. If your backend can do the JOIN for you, take it. But if it can’t, keep reading.
You will see this called different things depending on where you read about it. Derived queries. Query composition. Combining queries. Same question.
In this article I am exploring different ways the community and myself have thought of to solve this problem, each with its own trade-offs.
Spoiler alert: There is no one best solution, but a solution listed here might help you in your particular situation.
The problem
Back to the portfolio example. The user connects several accounts, let’s say 3 accounts:
- stocks account
- precious metals account
- crypto account
Each account holds several assets. The overview page looks something like this:
Total value
$48,150.00
Asset Allocation % Value
───────────────────────────────────────────
AAPL ██████░░░░░░░░░░░░░░ 32% $15,408
VWRL █████░░░░░░░░░░░░░░░ 24% $11,556
BTC ████░░░░░░░░░░░░░░░░ 18% $8,667
MSFT ███░░░░░░░░░░░░░░░░░ 16% $7,704
GOLD ██░░░░░░░░░░░░░░░░░░ 10% $4,815
───────────────────────────────────────────
100% $48,150Given the 2 available GET endpoints mentioned above, let’s try to derive the total portfolio value.
1. Combine in the component
The most obvious one is to combine the data in the render function of the component, the body of our wrapper hook, by calling useQuery/useQueries for each piece of data and stitching them together:
const usePortfolioValue = () => {
const accountIds = useAccountIds()
const { data: prices } = useQuery(pricesQuery())
const { data: assets } = useQueries({
queries: accountIds.map(assetsQuery),
combine: mergeAssets
})
return useMemo(() =>
reduceTotalValue(assets, prices),
[assets, prices]
)
}
// module scope, so the reference stays stable
const mergeAssets = (assetsPerAccounts) =>
assetsPerAccounts.flatMap((account) => account.data ?? [])This is a totally valid approach. It is important, however, to understand what happens under the hood. Let’s do some arithmetic. Our usePortfolioValue() hook will create several Observers once it’s called, one for each Query. One for the prices data, and one for each account data. In our example of 3 accounts, that is 4 Observers per consumer, one for prices and one for each account, for every use of usePortfolioValue() across our app.
Another thing worth noticing is what happens when our cached data changes.
- If prices change, the prices Observer notifies the component and it rerenders. The
mergeAssets()function, however, won’t run. It lives in a different Observer, the one watching the accounts, and none of the queries that Observer watches has changed.combineis memoized per Observer, so it hands back the previous result untouched. TheuseMemo()deps do change, of course, so we get a new portfolio value. - If an account’s data change, the corresponding Observer will run the
combinemethod (ourmergeAssets()) which will return new results.useMemo()runs again and our components rerender.
2. Combine in combine of useQueries
Did you notice how useful that combine memoization is? This next approach hands all the queries to one useQueries call, so the whole derivation gets it:
const usePortfolioValue = () => {
const accountIds = useAccountIds()
const { data } = useQueries({
queries: [
...accountIds.map(assetsQuery),
pricesQuery()
],
combine: combineResults
})
return data
}
// prices was listed last, so everything before it is an account
const combineResults = (results) => {
const assetsPerAccounts = results.slice(0, -1)
const prices = results.at(-1)?.data ?? {}
return reduceTotalValue(mergeAssets(assetsPerAccounts), prices)
}reduceTotalValue() and mergeAssets() stay the same as before, nothing new here about them. What is new is that reduceTotalValue() is no longer called at the component/hook render function level but rather in the Observer level, just like our mergeAssets() from before. That means the result is memoized, and the output of combine runs through structural sharing before it reaches the consumer, so the components rerender only when the portfolio value actually changes. If, for example, prices of two assets change together and they cancel each other out, the components won’t rerender. I wouldn’t judge you if you think this is a stupid example. But at least it demonstrates my point.
Did you notice something, though? What happens now if only 1 price changes? The Observer will run combineResults() which itself will run mergeAssets() and the new portfolio value is returned. In our previous approach, mergeAssets() didn’t need to run. The assets didn’t change, only a price changed.
You traded one optimization for another. If your mergeAssets() is the expensive one, prefer the first approach. If your reduceTotalValue() is the expensive one, prefer the latter one.
(I’d add that you’re also trading some code readability.)
As for the number of Observers, nothing changed. Still 4 Observers per consumer. Same queries, just combined in a different place.
You are happy with your usePortfolioValue() hook, you pat yourself on the back, and you start using it around in your codebase. In fact, you realize you need to calculate the allocation percentage for each asset row, so you decide to use your new hook on each asset row. Now, usePortfolioValue() has 6 consumers, the header value, and each row.
(If you think this example is not realistic and that the developer should instead prop-drill the value or put it in a Context, you have a point. That is a discussion of its own and I cover it in a follow-up. Here it is just a way to illustrate the other approaches.)
Total value
$48,150.00 <- usePortfolioValue()
Asset Allocation % Value
───────────────────────────────────────────
AAPL ██████░░░░░░░░░░░░░░ 32% $15,408 <- usePortfolioValue()
VWRL █████░░░░░░░░░░░░░░░ 24% $11,556 <- usePortfolioValue()
BTC ████░░░░░░░░░░░░░░░░ 18% $8,667 <- usePortfolioValue()
MSFT ███░░░░░░░░░░░░░░░░░ 16% $7,704 <- usePortfolioValue()
GOLD ██░░░░░░░░░░░░░░░░░░ 10% $4,815 <- usePortfolioValue()
───────────────────────────────────────────
100% $48,150You think to yourself, “hmm… so much wasted computation. Every time I use usePortfolioValue(), the results of reduceTotalValue() and mergeAssets() need to be recalculated.” And you would be correct. No matter which of the above approaches you use, the results are cached either at the component level (in useMemo()) or in the Observer level (in combine). The results are cached per consumer.
There are several ideas on how to share a cache between consumers. One idea is to move the memoization outside of the components and the Observers completely by using a separate library like fast-memoize.js. There are, however, a few more ideas that don’t require an additional package, which you might find useful.
3. Combine in queryFn with Promise.all
One of these ideas is to move the derived value cache one level deeper. In the QueryCache itself. Who controls what goes in the QueryCache? The queryFn function of a Query. So here’s a take on it, using Promise.all():
const usePortfolioValue = () => {
const accountIds = useAccountIds()
const { data } = useQuery(portfolioQuery(accountIds))
return data
}
const portfolioQuery = (accountIds) =>
queryOptions({
queryKey: ['portfolio', accountIds],
queryFn: async () => {
const assetsPromises = Promise.all(accountIds.map(fetchAccountAssets))
const [assetsPerAccounts, prices] = await Promise.all([
assetsPromises,
fetchPrices()
])
return reduceTotalValue(assetsPerAccounts.flat(), prices)
}
})The derivation of the portfolio value now happens at the Query level and stored in the QueryCache. It is calculated on every refetch. It is not calculated per consumer anymore. When all 6 of the above components mount, the calculation will run only once. Your components now have a cache they can share. But at what cost?
Since the QueryCache now stores only the derived value, any component that simply needs the price of an asset needs to fetch it again:
const { data: prices } = useQuery(pricesQuery())So one cost of a shared derived cache is additional network requests. You’ve lost granularity.
Another cost is error handling. I purposefully left loading and error states out of scope to keep the article content simple. But it’s worth talking about it here. With the above Promise.all() implementation, if any of the API calls returns an error, the whole Query throws and will be retried again, depending on your configuration. Imagine an extreme scenario where your app has 100 accounts, not 3. Fire 101 requests at the same time and you will most probably hit some rate limiting and 429s. Even if only 1 out of those 101 returns a 429, all 101 requests will then be retried.
This again, is a trade-off against additional network requests, and an even more dangerous one that you need to watch out for. You could, of course, reach for Promise.allSettled() but that wouldn’t solve your retry problem, unless you re-implement retries within the queryFn but let’s not get there.
Last thing worth mentioning: you are now down to 1 Observer per consumer, from 4. The whole fan-out moved inside the queryFn.
4. Combine in queryFn with fetchQuery
This loss of granularity really bugs you. But then you remember: The queryFn doesn’t care what’s inside as long as it’s a Promise. API calls return promises, of course, but so do queryClient.fetchQuery calls. What if we replaced our raw API calls from the example above with calls to the QueryCache?
const usePortfolioValue = () => {
const accountIds = useAccountIds()
const { data } = useQuery(portfolioQuery(accountIds))
return data
}
const portfolioQuery = (accountIds) =>
queryOptions({
queryKey: ['portfolio', accountIds],
queryFn: async () => {
const assetsPromises = Promise.all(
accountIds.map((id) => queryClient.fetchQuery(assetsQuery(id)))
)
const [assets, prices] = await Promise.all([
assetsPromises,
queryClient.fetchQuery(pricesQuery())
])
return reduceTotalValue(assets.flat(), prices)
}
})Now, look at that. With only a two-line change, each account keeps its own cache. Prices too. So we can still make the below calls anywhere in our app and we will hit the same cache:
useQuery(assetsQuery(accountId))
useQuery(pricesQuery())Depending on staleTime, fetchQuery returns the cached value for everything still fresh and only fetches what was invalidated. And the composed result gets an entry too, computed once and read by everyone. I felt very proud when I came up with this idea, and very happy when I later discovered that TkDodo pointed at this solution as well, in a discussion titled, aptly, Derived Queries.
You must realize though what you have just traded, once again. A derived entry in the QueryCache is a snapshot. Not a subscription. If the prices query is invalidated, no consumer of usePortfolioValue() will re-render. The portfolioQuery has not been invalidated. Query invalidation for the portfolio value is now on your hands. I’ll save you the Uncle Ben quote here.
Nevertheless, it can be useful. If your app is built around manual invalidations, this can be a totally valid approach. In an app I’ve worked on I use a “change detection” query that asks the server “are there new data?” and invalidates several queries only when the answer is yes.
And like the previous approach, you are down to 1 Observer per consumer. Unlike it, each source keeps its own cache entry.
Which one to reach for
| Observers per consumer | Derivation runs | What you give up | |
|---|---|---|---|
| 1. Combine in the component | 4 | once per consumer | nothing shared between consumers |
2. Combine in combine | 4 | once per consumer | any query data change re-runs the merge too |
3. Promise.all in queryFn | 1 | once, shared | granular caches, and one failure retries everything |
4. fetchQuery in queryFn | 1 | once, shared | you own invalidation of the derived entry |
Reading it as a decision:
- Few consumers? Stop at approach 1 or 2. The sharing the others buy you is not worth the trade. Pick between them by which function is the expensive one,
mergeAssets()orreduceTotalValue(). - Many consumers, and nothing else in the app needs the raw sources? Approach 3 is the simplest thing that shares the work. Watch the error handling.
- Many consumers, and other screens do need the sources? Approach 4. Pay for it by invalidating the derived entry yourself. Or use an external library, which comes with its own trade-offs.
None of these is the right answer in general. They are four different answers to “where should this value be computed and cached”. Each of your use-cases can have a different answer. You are not restricted to using only one of them.
Conclusion
All above approaches are different ideas of how to implement your useQuery/useQueries hook wrappers. We started simple, by deriving and memoizing the data in the render functions, moving to memoizing them in the Observer level, and finally bringing them inside the QueryCache so that all consumers can have access to the same derived data. There are some things though that I alluded to but haven’t talked about.
I keep counting Observers and I never said why it matters. That turns out to be an article of its own, because it is really a question about where you call these hooks rather than how you write them.
TanStack Query encourages you to call them wherever you need the data. That advice is good, and it is not free. I built a slight variation of this portfolio page in nine different ways and measured each one. I’ll present my research in a follow-up article.
What do you think about these approaches? Have you thought of other ones? Did I miss a hidden trade-off? I’d be very happy to hear about it!
Comments
Thoughts, corrections or war stories are all welcome. Comments run on GitHub Discussions, so you need a GitHub account to post one.