Can I give every Suspense query a random unique queryKey?

It has proven quite difficult to use queryKeys consistently across a large codebase where it’s hard to know if a different component on the same screen is using the same query with the same variables. We’ve had a few squirrely bugs where missing query keys caused unexpected behaviour. This led me to think “well we should give every query a queryKey just in case another instance of the same query is added somewhere else”, which I figured could be accomplished pretty easily by wrapping the hook like

export function useSuspenseQueryWithUniqueKey(query, options) {
  const [queryKey] = useState(() => Math.random())
  return useSuspenseQuery(
    query,
    options === skipToken ? skipToken : { queryKey, ...options },
  )
}

But then I thought there must be a reason why Apollo doesn’t do this by default. Would it have negative impacts that I’m not seeing? Are there situations where we want multiple hooks to share a Suspense cache key?

Hey @jbachhardie :wave:

This is a great question! There’s both a philosophical reason and technical reason we don’t create our own queryKey by default.

But then I thought there must be a reason why Apollo doesn’t do this by default

I’ll start with the philosophical part here.

We typically recommend that you hoist your queries up to your route components and use fragment colocation and composition in order for components to describe their data needs, rather than using query hooks in a bunch of components to get access to that data. The useFragment hook is useful for this purpose as well as it allows you to read data out of the cache while avoiding network fetches.

We don’t quite yet have this recommendation hammered out in a coherent way in our docs beyond that small section on the “Fragments” page, but we will be pushing this pattern more in the coming months, especially after data masking lands (likely experimental in 3.12 and stable in 3.13). Over time, we’ve seen that Apollo Client users tend to overuse queries in components, either because they want to avoid prop drilling, or because its viewed as a way for the component to guarantee it gets the data it needs. I’ll admit though that on the second point, the Apollo Client docs haven’t helped as they typically have recommended this pattern (something we will changing!). Fragment composition/colocation tends to naturally solve this queryKey problem because it means you’re only using a single useSuspenseQuery hook anyways.

Beyond that, queryKey only matters when you’re using the same query/variables in two useSuspenseQuery hooks mounted at the same time. That queryKey just makes sure that each instance of the hook gets a unique ObservableQuery instance rather than sharing the same one. I’ll explain the technical reason why this is in the next section, but for cases like this, we typically recommend lifting the hook up to a common ancestor and passing data down via props, similar to how React recommends lifting state up. We recognize that we can’t enforce this pattern though however, so queryKey was meant as more of an escape hatch for users that end up sharing queries between components.

Now for the technical reason:

Would it have negative impacts that I’m not seeing?

In your example, using a random query key unfortunately doesn’t work here. Due to the way Suspense works, React unfortunately does not save the value in useState when the initial render suspends. This is true of useState, useRef, and useId. This means that there is no way to create a stable value in your component if your component suspends on its initial render. This took us a while to figure out in Apollo Client’s Suspense implementation and its the reason we had to create our own Suspense cache which stores references to the query so we can recall it after the component finishes suspending (the Suspense cache is invisible to end users though since we wanted to keep it an implementation detail :slightly_smiling_face: ).

Coming back to your example, since you’re creating a random value in a useState hook, you won’t have a stable identifier to work with if useSuspenseQuery suspends on your initial render. This would result in infinite fetches since it would mean that a new query would be created each time the component finishes suspending and would re-suspend again.

Because of this technical challenge, there is no reasonable value we can use for queryKey in Apollo Client that makes sense here, hence why our default key is composed of the query/variables combo (which is a much more guaranteed stable value) and queryKey is a user-defined value. We would have loved to figure out a way to create our own stable identifier because it would have meant that we could avoid queryKey entirely, but its just not how Suspense works.

If you’re keen on creating a wrapping hook, I’d recommend creating some kind of long-lived cache that you can store your queryKeys. I just don’t know that I have a great recommendation for generating a stable value automatically (for all the reasons that Apollo Client couldn’t do it).

Hope that helps!

Yeah that does help, thanks for the answer. I understand the technical reasons why you didn’t do something like this now, which is what I needed.

The philosophical reasons I’m not so sure about. One of the best things about Apollo is being able to compose components freely with the certainty that they will load their own data requirements if they’re mounted somewhere that hasn’t preloaded them but not make any additional requests if mounted somewhere that does already load them. Fragment composition feels like a step back into a world where we have to manually manage data fetching (useQuery) vs data usage (useFragment) and things break if those two aren’t carefully kept in sync. Not to mention losing the ability to have granular loading and error states, like “this widget failed to load its data, retry only its query while keeping the rest of the screen usable”. What’s the big advantage of fragment composition that justifies this?

I’ll try and keep this short as I can because could give a whole talk on why I think fragment composition is beneficial :laughing:.

I’m curious though what you mean by this:

Fragment composition feels like a step back into a world where we have to manually manage data fetching (useQuery) vs data usage (useFragment) and things break if those two aren’t carefully kept in sync

Could you explain that a bit more for me?

What’s the big advantage of fragment composition that justifies this?

One of the biggest advantages is the reduction in network requests. One of the main selling points of GraphQL is the “load only the data you need” aspect of it. If you have multiple queries that load overlapping data, you’re not only sending multiple network requests, but you’re transferring network bytes for data that you’ve already loaded somewhere else.

From a UX perspective, its also a much nicer user experience if you only see a single spinner rather than several spinners on the page because it reduces the “popcorn” effect you get due to varying network latencies between queries (Suspense certainly helps here too though). Far too often I feel like we hurt the UX because “my components can load their own data so I can keep them isolated from each other”. It certainly is great to code that way, but often this comes at the cost of good UX.

This pattern doesn’t necessarily mean that you can’t use multiple queries in strategic places (like if an isolated widget loads its data lazily, as your example states), its more trying to nudge you to avoid an explosion of network requests. I’ve seen some usages of Apollo Client that result in 1000+ useQuery hooks mounted on a single page at the same time. While this isn’t 1:1 with network requests, that page still executed about 20-30 network requests. This absolutely has an effect on UX and perceived performance of the page. We think this goes against some of the core principles that make GraphQL great to work with.

If you’re trying to be careful and avoid many network requests, the best bet is to lift your queries up higher in the React tree. Doing this refactor though usually means that components that used to load this data still need to access it. Without fragments, this means that each time you lift up a query, you need to be careful to remap all the fields from each child component into the query, otherwise you break your app.

As requirements change and your components need updating, you may find yourself needing to load an additional field that you didn’t before. If you’ve lifted your queries up to parent components, this means traversing up the React tree until you find the component that contains the query in order to add the field to the query. This gets more difficult the more reuse that component gets since it would require you to find every query that loads data for that component to add the new field. This can be very error prone if you’re not careful. TypeScript can certainly help, but it can be a decent amount of manual work.

Fragment colocation solves this by allowing your components to declare their data needs which keeps them more isolated and more loosely coupled to their parent components. A colocated fragment means that adding that additional field is as simple as adding that field to its declared fragment. That will naturally propagate to every query that includes that fragment in its selection set without ever having to touch those queries.

Something I should mention here is that you can use this fragment colocation pattern without the use of the useFragment hook. This just becomes a matter of passing the object down via props rather than pulling it from useQuery or useFragment. In fact, this is how much of our Spotify showcase is built right now. Feel free to take a look at that if it would help to see an example of this.

We’ll have much more to say in the future that should hopefully be more convincing, but those are a few reasons I can list off the top of my head. I’m sure it would help to have more of a real example, but I’ll leave that for a future talk.

Sorry for taking so long to respond. It’s been a busy week and I also have enough material to give a whole talk on this lol.

Could you explain that a bit more for me?

So, the problem with useFragment is that it fails unrecoverably if the data it needs isn’t in the cache, so you need to make sure that wherever it is used there has previously been a query fetching that fragment. There is, to my knowledge, no automated way to check this, so it’s a manual check by each developer when using a component. This by itself would be somewhat tedious but it gets especially dangerous because checking isn’t at all straightforward for a few reasons:

  1. Component trees can be quite complex, especially with things like feature flags potentially branching things at multiple points. Dozens or even hundreds of components might be involved, and it becomes very hard to know that you haven’t missed one.
  2. You can’t just run the code and check if it fails, because data for the fragment in question might be in the cache even if the fragment was never fetched, if its requirements are fulfilled by other fragments. This can result in very subtle bugs. For example, it might be that a screen that uses a given fragment works perfectly fine in the usual route to get to that screen because a previous screen coincidentally fetches the required data but in a rare edge case where users get to the screen from a different route, the cache is not populated and useFragment fails. This is not a hypothetical, it’s an entire class of real bugs with Redux “request and store” implementations that I’ve experienced and which are one of the main reasons in my mind for choosing a system like Apollo that guarantees a way to resolve component data requirements instead of relying on other components to have done data fetching before.

One of the biggest advantages is the reduction in network requests. One of the main selling points of GraphQL is the “load only the data you need” aspect of it. If you have multiple queries that load overlapping data, you’re not only sending multiple network requests, but you’re transferring network bytes for data that you’ve already loaded somewhere else.

It’s funny you say this because in my experience fragment composition increases network requests and data transferred over the network. Imagine the following:

Screen A needs Fragments A and B
Screen B needs Fragments A, B, and C
Screen C needs Fragments A, B, and D

If a user is navigating A => B => C then because there’s never perfect overlap between requirements then fragment composition will make three requests:

  1. AB
  2. ABC
  3. ABD

This means requesting A and B three entire times, despite the fact that that data is in cache after the first request. Meanwhile, if each fragment was fetched in a different query, the fragments we fetched would be:

  1. AB (two queries, but they’re easy to batch into one connection either with a Link or just HTTP2)
  2. C
  3. D

This is a lot less data fetched, especially because this pattern has a tendency to repeat across an entire application: it’s very common for features to all require some core fragments (user data, basic account data, etc.) but then differ in what small amount of additional data they need for their specific function.

From a UX perspective, its also a much nicer user experience if you only see a single spinner rather than several spinners on the page because it reduces the “popcorn” effect you get due to varying network latencies between queries (Suspense certainly helps here too though). Far too often I feel like we hurt the UX because “my components can load their own data so I can keep them isolated from each other”. It certainly is great to code that way, but often this comes at the cost of good UX.

I mean, yes, but that’s why the React team invented Suspense, no? There’s also other ways around this, you can mount a non-reactive version of all the queries at the top level of the screen and use them to control unified loading. It’s an easy problem to notice in testing if it starts being disruptive to UX and an easy problem to solve if so. Since it’s not always applicable (often you want things to load staggered, if they’re under the fold) and easy to solve when it is I wouldn’t really want to structure my whole app to work around it.

I’ve seen some usages of Apollo Client that result in 1000+ useQuery hooks mounted on a single page at the same time. While this isn’t 1:1 with network requests, that page still executed about 20-30 network requests. This absolutely has an effect on UX and perceived performance of the page. We think this goes against some of the core principles that make GraphQL great to work with.

Are 20-30 network requests worse than one big network request fetching 20-30 fragments? Especially with HTTP2 multiplexing meaning that requests can be parallelized very cheaply, there’s distinct advantages to making more smaller queries rather than fewer large ones, like easier error handling and smoother load distribution on the backend.

I have a very different approach that I have successfully implemented in multiple large production applications: don’t have queries defined per component, but rather a single shared library of non-overlapping queries defined per domain entity. This is because:

  1. Fetching more fields from a single entity is, within reason, really cheap, so it’s often a good tradeoff to fetch a few more fields than necessary upfront so they’re cached and you don’t have to reload the whole entity for just one or two extra fields in the future.
  2. We tend to think about things in entity terms, anyway, since fields are too granular to fully keep track off. e.g. “this component needs the user’s Account and Preferences”
  3. Having fewer queries that are shared maximizes the usefulness of both the cache and query deduplication. The objective is that any given piece of data only needs to be requested once per user session. When a user is spending more than a few minutes using the application, this can quickly eliminate practically all loading times as the user gets to operate entirely against the local cache, which is updated with the result of mutations. And if some data needs to be fresher than that it’s easy to add network-only to a single query and refetch the minimum amount necessary.

With fragment composition, where each component is defining really granular fragments, not only is there more boilerplate (especially when refactoring and splitting up or merging components, oof) and basically every single developer needs to be really familiar with the exact structure of the GraphQL schema and tooling in order to do anything, but you lose most of the benefits of the normalized cache. If every screen is issuing a unique query with a unique combination of fragments, then chances are you’ll almost never get a cache hit and you’ll be fetching every fragment for every screen every time. I just don’t see how that’s desirable for any complex application where you can expect users to be doing many operations on the same underlying data over the course of a session.

1 Like

First of all, amazing response! Its clear you’ve thought very deeply into this and I think you have solid reasons for the choices you’ve made.

After reading this, I’m realizing a lot of our differences in opinion are the differences in experience. Having sat in the maintainer chair for some time now, there are many cases where I’ve seen developers blindly loading their components with useQuery simply because its “isolated” or “nice for DX” without thinking through what this does to the UX. Our (soon to be) recommendation for moving to fragments is to target this audience. Our docs haven’t done a great job of this in the past and I believe a lot of the rampant “just add useQuery” usage is our fault. I think fragments will really help this audience. So my opinion here is from the perspective of seeing them under utilized and having worked with codebases that were brittle or inefficient without this pattern.

At the end of the day though, it will be recommendation, not an enforcement, which means you can take or leave it. Again its clear you’ve put a lot of thought into this and have developed a lot of solid opinions on the pros/cons so our recommendation may not apply to you. Your perspective though is really helpful to think about and something I’d love to provide answers for in the fragment realm for the future.

So, the problem with useFragment is that it fails unrecoverably if the data it needs isn’t in the cache

This is a fair criticism and one I’d like to find a solution for. Our data masking RFC currently leaves these problems as open questions. I’d love if useFragment can be used to read both non-normalized data and data in no-cache queries. Unfortunately this isn’t likely to land in the initial data masking feature launch, but is something I’m continuing to explore. I think if we are able to solve both of these issues, useFragment could become a lot more useful for your situation since it would become a hook that is less specific to just the cache and is more about reading fragment data out of a query.

Again, still lots to explore here, but it is on our radar :slightly_smiling_face:. We’ll be adding suspense support to useFragment which will be super useful for @defer to enable more granular loading states.

Once again, really appreciate the engagement! I always love to hear different perspectives that challenge my thinking!