Urql: Best practice to update cache on mutation insert/delete for large applications?

Created on 29 Feb 2020  Â·  9Comments  Â·  Source: FormidableLabs/urql

This is more of a question related to best practice for create/delete mutations. I noticed that the urql graph cache does not update automatically when things are added / removed via a mutation. After exploring the documentation and examples, I was able to figure out a solution that works, but I'm not sure if it's the best way. So before doing this for all of the mutations across our application, I figured I'd check in here :).

Some background - we're using https://hasura.io to drive much of our graphql API, and their insert/delete mutations work with multiple objects (similar to batching). Here are some example mutations / queries (note they're all returning __typename: star).

mutation StarNode($projectId: Int!, $nodeId: Int!) {
  createStars(objects: [{ projectId: $projectId, nodeId: $nodeId }]) {
    returning {
      id
      __typename # of type star
    }
  }
}

mutation UnstarNode($projectId: Int!, $nodeId: Int!) {
  deleteStars(where: { nodeId: { _eq: $nodeId }, projectId: { _eq: $projectId } }) {
    returning {
      id
      __typename # of type star
    }
  }
}

query GetNodeStar($projectId: Int!, $nodeId: Int!) {
  stars(where: { nodeId: { _eq: $nodeId }, projectId: { _eq: $projectId } }) {
    id
    __typename # of type star
  }
}

After some fiddling, this is what I came up with. Is this recommended way to make sure the UI / cache updates in response to insert/delete mutations? It works, but it feels like it won't scale well in a large application that has lots of different queries, with different variables, etc - particularly because we have to make sure to pass the originally used variables. The creation of this cache is quite far away from the queries happening in our application, so it feels like it will be easy to make a mistake and not cover all the variable combinations, etc.

import { cacheExchange } from '@urql/exchange-graphcache';

// @ts-ignore
clientCache = cacheExchange({
  updates: {
    Mutation: {
      createStars: (result: StarNodeMutation, args, cache, info) => {
        cache.updateQuery(
          { query: GetNodeStarDocument, variables: info.variables },
          // @ts-ignore
          (data: GetNodeStarQuery | null) => {
            if (data) data.stars.push(...(result.createStars?.returning || []));

            return data;
          },
        );
      },
      deleteStars: (result: UnstarNodeMutation, args, cache, info) => {
        cache.updateQuery(
          { query: GetNodeStarDocument, variables: info.variables },
          // @ts-ignore
          (data: GetNodeStarQuery | null) => {
            const deletedIds = (result.deleteStars?.returning || []).map(s => s.id);
            if (data) {
              data.stars = data.stars.filter(s => !deletedIds.includes(s.id));
            }

            return data;
          },
        );
      },
    },
  },
});

Random Idea?

I'm left wondering if URQL could remove much of this complexity with a hint from the developer? Something like this (the relevant lines are those with comments):

const NodeStarButton = ({ node, projectId, isStarred }: { node: INodeInfo; projectId: number; isStarred: boolean }) => {
  // these are using URQL useMutation hook under the hood, with the same response signature
  const [, starNodeMutation] = useStarNodeMutation();
  const [, unstarNodeMutation] = useUnstarNodeMutation();

  return (
    <Button
      text="Star"
      icon={isStarred ? 'star' : 'star-empty'}
      intent="primary"
      onClick={async () => {
        if (isStarred) {
          unstarNodeMutation(
            { projectId: projectId, nodeId: node.id },
            {
              // any nodes in the response with __typename: star should be removed from the cache
              deleting: ['star'],
            },
          );
        } else {
          starNodeMutation(
            { projectId: projectId, nodeId: node.id },
            {
              // any nodes in the response with __typename: star should be added to the cache
              creating: ['star'],
            },
          );
        }
      }}
    />
  );
};

Basically allowing the user to hint that any nodes with typename [type1, type2, ...] in the response should automatically be added or removed from the cache. This does not cover more complex cases of course, but it seems like it could cover a pretty common use case (I want something generally removed from the cache, across all queries, when deleted, etc). End user still has the updates feature in graph cache for more complex cases.

This is a random idea, I admittedly do not quite understand all the URQL internals yet, so this might not make much sense.

Typings

A note on typings - we generate typings from our GraphQL API and client queries in order to guarantee that nothing every gets out of sync. The way that the cacheExchange types are put together, we can't use our types in these update functions. The @ts-ignore statements you see in the code snippet above are to ignore the typings problem.

If this seems like a legitimate thing to fix LMK and I can create a separate issue for typings improvements.

Here's what vscode reports without the ignores, for the two different classes of ignore in code above:

Screen Shot 2020-02-28 at 7 39 15 PM

Screen Shot 2020-02-28 at 7 39 39 PM

All 9 comments

Hiya 👋

Thanks for the detailed issue! I hope we can use this to clarify some things in our upcoming & improved docs! Also please consider this a tentative reply, until I can cover more ground in my reply 😅

First of all, we can’t assume any “hints” or automated changes for lists and “links” — links being connections from one entity or another, so for instance Query referencing a Star in your case. This is because we don’t know what your server-side logic looks like.

Also, we don’t want to alter the way that mutations are sent. Graphcache’s main target is: don’t be specific in your React logic, define every exception to the rule in one case. So basically we want to avoid duplication exactly by making sure that you don’t define updates in your application code.

What you care up with is generally what we recommend: it’s short, it’s concise and it gets the job done. If you chose to use fragments there, you also don’t need to share the query itself. Every mutation that’ll alter other queries will also need to alter the normalised data. So that doesn’t mean that you need that specific query to update, and that’s why this configuration code can still be considered rather generic.

Regarding types, we’re trying to figure that out but ultimately that’s unfortunately a hard problem. Sorry! 😕

Got it, thanks for the prompt response!

If you chose to use fragments there, you also don’t need to share the query itself.

Sounds interesting but I'm not sure I'm following this one - are there examples somewhere by any chance?

In going through the exercise of adding cache update handlers for our various mutations, I've run into another issue - wondering if you have any ideas.

This is what the cache update handler looks like:

deleteWorkspaceMembers: (result: RemoveWorkspaceMemberMutation, args, cache, info) => {
  cache.updateQuery(
    // variables is difficult to determine, since this is a paginated query, supports optional arbitrary search var, etc
    { query: WorkspaceMembersDocument, variables: ??? },
    // @ts-ignore
    (data: WorkspaceMembersQuery | null) => {
      const deletedIds = (result.deleteWorkspaceMembers?.returning || []).map(s => s.id);
      if (data) {
        data.workspaceMembers = data.workspaceMembers.filter(s => !deletedIds.includes(s.id));
      }

      return data;
    },
  );
},

This workspaceMembers query is a paginated query (using graph cache and the simplePagination helper) - it also supports a "search" variable that can be any string. Thus, there are an indeterminate number of variable combinations - ideas re how should we go about updating the cache to delete the removed member?

Kind of feels like delete might be a special case - seems query agnostic. If something is deleted on the server, the majority of the time the end user probably wants that item removed from the global cache (regardless of query).

We had a method on cache planned for this, but I think the intent to add it got lost in the noise. Specifically we have a methods that is relevant to this

Cache#invalidateQuery removes all data related to an entire query: https://github.com/FormidableLabs/urql/blob/3e8b4d700eca4e25aee6d8c08a99ba2bcbcad848/exchanges/graphcache/src/types.ts#L102-L10

This method is mostly obsolete as far as I can tell, because of the automatic garbage collection and instead, we wanted to add a Cache#invalidate method. As I envision it this method would allow you to invalidate an entire entity by dropping it from the cache.

Invalidating an entity would involve calling this method with your partial entity or key:

cache.invalidate({ __typename: 'Member', id: 'xyz' });

This would delete the entire entity from the cache and the garbage collection would take care of then also deleting all unreferenced "children" of this entity. All other entities that are referring to the now deleted entity would point to "nothing".

In your case that would lead to these queries being "partially cached" as some data would now be missing. If you're using cache awareness your lists would still be returned, albeit some items being null, otherwise the result will be uncached and a network request will occur.

What you're describing sounds great!

In your case that would lead to these queries being "partially cached" as some data would now be missing. If you're using cache awareness your lists would still be returned, albeit some items being null, otherwise the result will be uncached and a network request will occur.

To make sure I understand - would calling invalidate for the entity that was deleted cause react components that use queries with that entity to re-render? The simple example here is a list of members (populated via a GQL call to get list of members) - when the member is removed (via a GQL mutation to remove the member, and subsequent cache.invalidate call) the list should re-render, this time without that removed member.

Yes, they will get notified of a change. Since we can't accurately reconstruct the list lists containing this entity will have to be refetched though. This because just collapsing wouldn't be a good idea, since if this would be a case of a background refetch it would make the UI jump.

However as mentioned in the PR, this can be circumvented if that entity is marked as optional in your schema (if your graphcache is schema-aware).
This would be a case where result.stale would be true since we are serving you partial data and a refetch is happening on the background.

I've ran into similar issues as @marbemac when it comes to updating a query that you can't ever know what values are currently being passed as arguments.

With apollo client this is possible to do, although cumbersome, by defining the updater function when you call useMutation. You can pass down the same state you used as variables to useQuery and use it on cache.writeQuery.

I prefer defining the updater functions separately from React logic, the urql way, but in this case its a limitation.

@ericnr hiya 👋 I’m playing with the idea of moving the argument filtering until after updates are run (and before the query is forwarded) so that variables can be passed using additional properties for the updates.

That being said, that’ll likely be a last resort, since you’d then be relying on specific variables being sent that are technically unrelated to your mutation.

So the alternatives are mostly:

  • using invalidate when entities are simply being removed
  • use fragments with writeFragment to update sub-queries and entities without arguments
  • use inspectFields to get all previous arguments for all fields of a given entity (or even just “Query”)

In the future the @populate directive from the populateExchange may also help, but currently it’s experimental since some questions around arguments haven’t been resolved for it yet either

The above fix that we’re looking to release soon, moves the filtering of variables to a later stage. So the API won’t see variables that the GraphQL query document doesn’t define, but you’ll be able to pass additional variables just for the cache updaters.

it seems a little bit of type-safety would be lost, but it solves the problem!

Was this page helpful?
0 / 5 - 0 ratings