How to evict all related queries from cache that reference ids of deleted entities

Hi :waving_hand:t2:

I’m trying to optimize the way we update the Apollo cache after deleting various entities. The Apollo Client docs have rather good examples about this topic but manually managing the cache can be quite challenging if you have many queries that reference the deleted entities (eg. in their variables).

So even if you evict the deleted entities from the cache it often is not enough as you need to also evict queries that “depended” on that deleted entity. For example if you deleted a post and had a separate query to fetch the comments of that post by its ID you would want to evict that query from the cache.

I’ve been wondering if there is an official way to easily evict from the cache all queries that have a reference to the deleted entity’s ID. This way we wouldn’t have to manually define all queries to refetch after deletion.

I’ve fiddled with a utility function that looks like this:

/**
 * Remove all cached queries that reference the given ID in their variables.
 * Use this when you need to cleanup the Apollo cache after a delete mutation.
 */
export function removeQueriesThatReferenceIds({
  cache,
  ids,
  broadcast = true,
}: {
  cache: ApolloCache<any>;
  ids: string[];
  /** Whether to broadcast the eviction to subscribers to refetch queries */
  broadcast?: boolean;
}) {
  const cacheData = cache.extract();
  const rootQuery = cacheData.ROOT_QUERY;

  if (rootQuery) {
    Object.keys(rootQuery).forEach(fieldKey => {
      const shouldEvict = ids.some(id => fieldKey.includes(id));

      if (shouldEvict) {
        // Parse field key to extract field name and arguments for eviction
        const match = fieldKey.match(/^([^(]+)(\(.*\))?$/);

        if (match) {
          const [, fieldName, argsStr] = match;

          if (argsStr) {
            try {
              const argsMatch = argsStr.match(/\((.*)\)$/);

              if (argsMatch) {
                const args = JSON.parse(argsMatch[1]);
                console.debug(`Evicting ${fieldName} with args:`, args);
                cache.evict({ id: 'ROOT_QUERY', fieldName, args, broadcast });
              }
            } catch (_error) {
              // If parsing fails, skip this field
              console.warn('Failed to parse cache field arguments:', fieldKey);
            }
          }
        }
      }
    });
  }
}

This seems to work but I’m not super happy about having to parse the query arguments in this way.

Does this issue make sense or am I approaching this totally wrong?

Here’s some more context on the problem - image the following scenario:

I have a list view with items. Each item also have a separate details view that can be visited by clicking the item link. I preload the detail view queries with `preloadQuery` upon hovering the item link. Without actually navigating to the item details view I delete the item from the list view. After the mutation is completed I update the cache by evicting the related item which removes the item from the list view. This cache update however causes the queries that were preloaded to refetch as they depended on that deleted item as well. These refetches fail as the item was deleted.

How do I stop those dependent queries from refetching? Even if I use my `removeQueriesThatReferenceIds` utility function to evict the preloaded queries from the cache they still get refetched.

Here’s how my function that updates the cache after the mutation looks like:

function removeItemsFromCache(itemIds: string[]) {
  const { cache } = getApolloClient();

  itemIds.forEach(itemId => {
    cache.evict({
      id: 'ROOT_QUERY',
      fieldName: 'item',
      args: { id: itemId },
      broadcast: false,
    });
  });

  removeQueriesThatReferenceIds({ cache, ids: itemIds });

  itemIds.forEach(id => {
    cache.evict({ id: cache.identify({ __typename: 'Item', id }) });
  });

  cache.gc();
}

Ah, that is interesting context!

I believe the problem here is that a preloaded query is an active subscription until it mounts and unmounts for the first time.

So if you never mount a component consuming that preloaded query, you end up with a lot of active queries (a potential memory leak).

So that’s where I would actually try touching this problem - if you just want to “load, but not necessarily use”, preloadQuery might not be the right tool. You could just make a client.query call instead.

Thanks for the reply :folded_hands:t2:

I think we have to move to using client.query + useSuspenseQuery instead of preloadQuery as discussed in this long Twitter thread.

You (the Apollo team) might want to mention this potential pitfall in the Usage with data loading routers docs page as I image this is quite a common use case and it is not very easy to detect this issue unless you closely monitor the GraphQL network calls during development.

Also I would love to know if there was any possibility to have an “inactive” version of the preloadQuery that would play well with this use case :slightly_smiling_face:

Actually, this is the first time anyone ever brought this up, so we were just not really aware of this pitfall.

I’m trying around right now, I believe there might be a way to make preloadQuery work for this use case without risk of memory leak :slight_smile:

1 Like

Okay that is quite interesting :thinking: I wonder if preloading is not a very popular pattern yet, even thought libs like Tanstack Router are gaining in popularity, or if people just have not discovered this issue yet :sweat_smile:

But thanks for investigating if it would be possible to make preloadQuery support this use case :folded_hands:t2:

1 Like

I’ve opened a PR to explore this.

This might be the way we are going with this, but no guarantees yet.

Please check out the PR, maybe try the PR build and give us feedback if this helps you/how it doesn’t :slight_smile:

1 Like

First of all, thank you for the effort already :clap:t2:

I only had a short moment to test your PR build as I realized we were still using Apollo Client v3 so I had to do the upgrade first :sweat_smile: Based on what I was able to quickly test your PR unfortunately doesn’t seem to fix the issue. I’m getting the same refetching behaviour as before.

I wonder if Tanstack Router is holding on to the preloaded loader results somehow which would stop them from being garbage collected. Please let me know if there is something I can do to help :blush: