Relay & GraphQL: Demystifying Optimistic Updates

·

6 min read

Update: this document applies to Relay Classic. If you are using Relay Modern, please have a look at RelayJS website to find out updated instructions.

At ZeeMee we rely on the power of GraphQL and Relay. Our backend has been built on top of Ruby on Rails. One of the problems we came across recently was performing optimistic updates in Relay in an efficient way.

Relay does a lot of magic; although the magic doesn’t come out of nowhere: you need to be prepared for it! In other words, your GraphQL server needs to support it.

Let’s go through a real example: Our latest feature on ZeeMee is ‘My Colleges’. Students can join their desired colleges and then they can see other students who are interested in those schools.

For this to work smoothly, we need to have optimistic updates in place.

All of the mutations need to wait on a response from the server before updating the client-side store. Relay offers a chance to craft an optimistic response of the same shape based on what we expect the server’s response to be in the event of a successful mutation. — Relay Docs

In our case, we were interested in adding and removing colleges from the list of interested colleges and seeing the reflected changes on the client immediately.

Our backend for this feature is structured as follows:

  • Organization model
  • User model
  • UserOrganizationStatus model which acts as the join table between users and organizations.

Our GraphQL’s UserType includes:

UserType = GraphQL::ObjectType.define do
   ...
   connection :interestedOrganizations, -> { OrganizationType.connection_type } do
resolve -> (user, args, _ctx) do
      user.interested_organizations
   end
end

UserType defines an interestedOrganizations connection which returns a collection of OrganizationType.

Our GraphQL mutation for updating organization affiliation needs two arguments: organizationId and interested flag. Based on these values, the mutation associates the user with the appropriate organization. When we mutate our data using mutations, Relay needs to be informed on how to update the client’s cache; that’s when the Mutator Configuration comes into play: it allows us to instruct Relay what to do with the mutation payload. Presently, there are five ways to use the Mutator Configuration:

  • FIELDS_CHANGE
  • NODE_DELETE
  • RANGE_ADD
  • RANGE_DELETE
  • REQUIRED_CHILDREN

In our use case we needed to support adding and removing nodes to and from a specific connection. For adding, RANGE_ADD config was used and for deletion we used NODE_DELETE.

RANGE_ADD: Given a parent, a connection, and the name of the newly created edge in the response payload Relay will add the node to the store and attach it to the connection according to the range behavior specified. — Relay Docs

To provide RANGE_ADD with the required info, the GraphQL server needs to put a couple things in the mutation payload: organizationEdge and me.

In our case, if the interested flag was set to true it meant that we are interested in adding a node to a connection and if it was false, it meant that we want to remove a node from the connection.

Using graphql-ruby gem you can easily create an edge and embed it in your payload:

# Make a connection, wrapping `object`
# first argument is the collection of results
# second argument is query arguments
connection = GraphQL::Relay::RelationConnection
                      .new(u.interested_organizations, {})
# create an edge between an organization and a connection
edge = GraphQL::Relay::Edge.new(s.organization, connection)
# return the following in the payload
{ organizationEdge: edge, me: u }

Adding the above snippet to the backend gives relay the essential pieces of information necessary in order to update the local store. On the client-side mutation, the following lets us to use the power of optimistic updates in order to do additions:

getOptimisticResponse() {
   var newOrg = {id: this.props.id, name: this.props.name};
   var newOrgs = this.props.currentOrganizations;
   newOrgs.edges.push({node: newOrg});
   return {
     organizationEdge: {node: newOrg }
   };
}
getConfigs() {
  return [{
    type : "RANGE_ADD",
    parentName : "me",
    parentID : this.props.me.id,
    connectionName : "interestedOrganizations",
    edgeName : "organizationEdge",
    rangeBehaviors : {
      '' : 'append'
    }
  }];
}

Deleting a node from connection is another story. Here we will use NODE_DELETE config to achieve optimistic updates.

NODE_DELETE: Given a parent, a connection, and one or more DataIDs in the response payload, Relay will remove the node(s) from the connection and delete the associated record(s) from the store. — Relay Docs

Removing the associated record from the relay store causes an immediate update on the client side. As the Relay documentation explains, NODE_DELETE needs a parent, a connection and a DataID from the server.

In case of deletion, there is no edge involved. Instead having nodeID (organizationId) and parent (me) in our payload is enough for Relay to update its client cache.

# Payload on the server side
{ ... organizationId: organization_id, me: u, ... }

On the client-side mutation:

getOptimisticResponse() {
  return {
     organizationId: this.props.id
  };
}
getConfigs() {
  return [{
    type : "NODE_DELETE",
    parentName : "me",
    parentID : this.props.me.id,
    connectionName : "interestedOrganizations",
    deletedIDFieldName : "organizationId",
  }];
}

This adds optimistic update on deletions as well. Combining optimistic responses and getConfigs for adding and removing allows us to have optimistic updates for both addition and deletion.

Here is the server side implementation. When receiving the interested variable from the client, the server decides if the current action is an addition or a deletion.

UpdateOrganizationAffiliationMutation = GraphQL::Relay::Mutation.define do
  name 'UpdateOrganizationAffiliation'
  input_field :organizationId, !types.ID
  input_field :interested,!types.Boolean
  return_field :organizationEdge, OrganizationType.edge_type
  return_field :organizationId, types.ID
  return_field :me, UserType
 resolve -> (inputs, ctx) {
    _, organization_id = 
      NodeIdentification.from_global_id(inputs[:organizationId])
 u = ctx[:current_user]
    params = {
      organization_id: organization_id,
      user_id: u.id
    }
    s = UserOrganizationStatus.find_or_initialize_by(params.compact)
    s.interested = inputs[:interested]
    s.save!
    connection = GraphQL::Relay::RelationConnection
                      .new(u.interested_organizations, {})
    edge = if inputs[:interested]
        GraphQL::Relay::Edge.new(s.organization, connection)
      else
        nil
      end
 { organizationEdge: edge, organizationId: organization_id, me: u }
  }
end

The client side mutation uses the same logic for emulating responses from the server:

import Relay from 'react-relay';
class UpdateOrganizationAffiliationMutation extends Relay.Mutation {
  getMutation() {
    return Relay.QL`
      mutation {
        updateOrganizationAffiliation
      }
    `;
  }
getVariables() {
    return {
      organizationId: this.props.id,
      interested: this.props.interested,
    };
  }
getFatQuery() {
    return Relay.QL`
      fragment on UpdateOrganizationAffiliationPayload {
        me {
          interestedOrganizations(first: 50) {
            edges {
              node {
                id
              }
            }
          }
        },
        organizationEdge,
        organizationId
      }
    `;
  }
getOptimisticResponse() {
    if (this.props.interested) {
      var newOrg = {id: this.props.id, name: this.props.name};
      var newOrgs = this.props.currentOrganizations;
      newOrgs.edges.push({node: newOrg});
      return {
        organizationEdge: {node: newOrg }
      };
    } else {
      return {
        organizationId: this.props.id
      };
    }
  }
getConfigs() {
    return [{
        type : "RANGE_ADD",
        parentName : "me",
        parentID : this.props.me.id,
        connectionName : "interestedOrganizations",
        edgeName : "organizationEdge",
        rangeBehaviors : {
          '' : 'append'
        }
      },
      {
        type : "NODE_DELETE",
        parentName : "me",
        parentID : this.props.me.id,
        connectionName : "interestedOrganizations",
        deletedIDFieldName : "organizationId",
      }];
  }
static fragments = {
    me() {
      return Relay.QL`
        fragment on User {
          id
        }
      `;
    },
  };
}
export default UpdateOrganizationAffiliationMutation;

Demystifying the Mutator Configuration is a key part in using Relay’s full power.