A Beginner's Guide To Relay Mutations

This is another technical post written by our Engineering team about what we're learning at Pathgather. If you're not a developer who is familiar with React & Relay, you'll probably be a bit confused!

Introduction

When I first got started using Relay a month or two ago, everything seemed easy. I was writing GraphQL fragments in my components, composing queries together using containers, and, since everything was being cached in the Relay store, I was browsing around the application crazy fast. It seemed like magic.

Then, I started experimenting with mutations and hit a brick wall. They just didn’t seem to make sense. I read all the guides, watched all the videos, but I still couldn’t seem to internalize everything well enough to actually make it work.

This guide is written for people like me who hit that “mutation brick wall”. It’s for developers who have played with Relay a bit, written some components, are pretty comfortable rendering data fetched from the server using GraphQL queries, but can’t quite figure out mutations. If that sounds like you, then read on! If instead you are brand new to Relay, you’ve got some homework to do first - the official Relay docs are a great place to start.

A Real Example: EndorseCatalogItemMutation

If you're like me, you hate reading about Todo apps. By now, you've probably looked at the TodoMVC code for Relay and still find yourself scratching your head at things like "fat queries", "mutation configs", and "optimistic responses"...

For this post, we're going to use a "real-world" example by talking about using Relay at Pathgather. I should preface this by saying that we haven’t shipped any of this code to production yet, so there’s still lots of learning for us to do, but at least this isn’t a toy example!

Some background on Pathgather first, so this all makes a bit more sense: we’re a enterprise learning platform that makes it easy for employees to find great learning content, share their favorite resources from around the web, and track their progress, all in one place. That means we spend a lot of time and energy showing users learning content: courses from online providers like Lynda, videos from sites like YouTube, blog posts shared by other users, etc. In our GraphQL schema, we call each of those things “CatalogItems”.

For this post, we’ll focus on a relatively simple example: a user has found a CatalogItem pretty valuable, so they want to "endorse" it so their peers can discover it too. This is a pretty simple interaction, of course, but we’ll layer on a bit more complexity at the end. Let’s see how we can model this specific action as a Relay mutation.

Five-Step Guide To Writing A Relay Mutation

I’ll walk you through how I think you should approach writing mutations in five easy steps. For now, let’s assume we’ve already written a React component that is happily rendering our CatalogItem on the client, which means our local graph is already storing some data in the form of a CatalogItem node (if you want some more info on how the Relay Store actually records that data, I highly recommend this great post that dives into all the details). The remote graph on the GraphQL server is much more complicated than that, of course. Here's what that might look like:

#1: Define Input Variables - i.e. "What portion of the graph are we targeting?"

For this mutation, the user is endorsing a specific CatalogItem. We need to think with graphs, not database rows, though - in a graph, you can only ever really target a node or an edge. In this case, we're definitely targeting a specific CatalogItem node, and the easiest way to find that node is to use the node's global ID (Relay makes this a requirement), so that’s what we’ll do. In other cases, you’ll end up targeting edges, or multiple nodes, or some combination of both - no matter what, though, remember you’ll be targeting a portion of the graph.

We can provide the ID of our CatalogItem node to the server via the input variables to the mutation. Relay collects these via the getVariables() method, which expects us to return an object with keys for each variable name:

getVariables() {
  return {
    id: this.props.catalogItem.id // === "123"
  }
}

With our input variables defined, that means our server should have enough information to locate the node we want to mutate in the remote graph (although someone will have to code that!). Here's where we're at:

#2: Define Name - i.e. "What should we call this operation to make it clear?"

Naming stuff is hard. You might chuckle that I even included this step, but I'm the kind of engineer that thinks naming stuff is super important, so I wanted to share some advice here. For me, the name comes second because I believe mutations should follow this basic format: <Action Name><Target Name>Mutation. Now that we've decided we're targeting an individual CatalogItem and endorsing it, the name is pretty obvious: EndorseCatalogItemMutation.

For our schema, we use snake case for everything (don't ask), so that corresponds to a mutation name of endorse_catalog_item.  We tell Relay this using the getMutation() method, which expects a GraphQL query:

getMutation() {
  return Relay.QL `mutation { endorse_catalog_item }`
}

Now that we've told the GraphQL server which mutation to run, it can actually make the change to the remote graph. Progress!

#3: Define Fat Query - i.e. "What portions of the graph does this mutation affect?"

Traditionally speaking, your old endpoints (things like "POST /comments") probably didn't specify all the possible things that could change as a result - how could they? Realistically, they probably return a custom document that contains the result of the operation, along with whatever else your client ended up needing at the time: the new comment, an error message, etc.

GraphQL takes a very different approach to this. Instead of the server specifying what is returned, the client needs to ask for what it wants. You can think of mutations as a write followed immediately by a read - we've already specified the write part (the name and input variables), so now we have to figure out what data needs to be read by the client. That's done via the mutation query.

However, we don't write the mutation query directly... and this is where people start to get confused. Since you're familiar with Relay already, you'll know that your components declare the data they want using GraphQL fragments. Mutation queries don't work like that. Instead of declaring exactly what data you want via a fragment, Relay tries to figure out the minimal amount of data you need in order to update your local graph. Relay then constructs the mutation query automatically by combining three sources of information together:

  1. Mutation Fat Query
  2. Mutation Configs
  3. Local Graph (Relay Store)

The first piece of information Relay needs from us is a "fat query". The fat query specifies what portion of the overall graph was affected by the mutation. You do this by writing a special kind of GraphQL query that specifies which fields may have changed, using some special syntax that Relay understands. Normally in GraphQL if you have a non-scalar field you have to specify which sub-fields you want to fetch (i.e. a field that has sub-fields, like a User field might have name, id, etc.). The fat query doesn't actually execute though, so you don't have to be that specific - if you just know that something on that field has changed in some way, you can just say "user". So the query ends up being less specific than a real query... which I guess makes it "fat"?

For example, in our EndorseCatalogItemMutation we know that we're affecting a very specific part of the graph - the CatalogItem node that was targeted by the mutation. We can specify a fat query that declares that anything about the catalog_item can change like so:

getFatQuery() {
  return Relay.QL`
    fragment on EndorseCatalogItemMutationPayload {
      catalog_item
    }
  `
}

...or, if we want to be more specific about what could have changed, we can specify the sub-fields that will be affected (fields like endorsement_count). This limits the possible mutation queries to ensure we don't overfetch something that can't have changed:

getFatQuery() {
  return Relay.QL`
    fragment on EndorseCatalogItemMutationPayload {
      catalog_item {
        endorsement_count
        is_endorsed_by_current_user
      }
    }
  `
}

We might not actually be rendering the endorsement_count anywhere, though, so fetching it would be unnecessary. This is where Relay starts showing off. Rather than construct a mutation query that fetches all the fields defined in the fat query, Relay intersects the fat query with the data your application has in the local graph to construct a query that only queries data you have fetched previously!

For example, let's say you have this catalog_item in the local graph:

catalog_item: {
  id: "123",
  is_endorsed_by_current_user: false
}

If we intersect the fat query with the fields defined on that catalog_item node, we get the following mutation query:

fragment on EndorseCatalogItemMutationPayload {
  catalog_item {
    is_endorsed_by_current_user
  }
}

Notice that we don't ask for the endorsement_count here because we haven't fetched it previously. If we play around on the site some more and a component fetches the endorsement_count at some point, the local graph will be updated to:

catalog_item: {
  id: "123",
  endorsement_count: 100,
  is_endorsed_by_current_user: false
}

We then reapply the intersection to form a new mutation query:

fragment on EndorseCatalogItemMutationPayload {
  catalog_item {
    endorsement_count
    is_endorsed_by_current_user
  }
}

Cool, right? Relay takes the guesswork out of constructing the mutation query since it knows you aren't currently showing the endorsement_count anywhere.

So, we've written a nice big fat query that specifies the portion of the remote graph that might have been affected by the mutation. Visually that looks something like this:

There's one thing missing here though - you might have 100 different catalog_item nodes in your local graph. How does Relay know which one to intersect the fat query against? That's where the mutation configs come in.

#4: Define Configs using FIELDS_CHANGE - i.e. "How should Relay intersect the fat query against your local graph to construct the mutation query?"

For our example mutation, the only thing missing is how to find the right node in the local graph to intersect the fat query against. Since all nodes are identifiable by their id, this is actually pretty simple to do - just configure the mutation to lookup the catalog_item node with id "123", and update the fields that may have changed. In Relay mutation terms, this is done by providing a FIELDS_CHANGE config object to the getConfigs() method:

getConfigs() {
  return [{
    type: "FIELDS_CHANGE",
    fieldIDs: {
      catalog_item: this.props.catalogItem.id // === "123"
    }
  }]
}

This is the missing piece of information that lets Relay lookup the appropriate parent node in the local graph to intersect the fat query against. It looks something like this:

If we combine the fat query, the mutation config, and the state of our local graph, Relay can now construct the mutation query that gets sent to the server:

Aside: On Mutation Configs

There are currently four* different valid mutation config types: FIELDS_CHANGE, NODE_DELETE, RANGE_ADD, and RANGE_DELETE.  Each config type takes a different set of supporting data to configure the intersection and the subsequent update of the local graph. Pretty much everyone I talk to gets totally confused by these config objects! In fact, you probably read about the different configs in the Mutations Guide, got confused, and found this blog post. Don't worry, we'll sort it out. Here's my advice for anyone getting started with Relay:

 

USE FIELDS_CHANGE FOR EVERYTHING.

 

All of the other configs are simply performance improvements over FIELDS_CHANGE that construct more efficient mutation queries (i.e. request less data). You don't need to optimize for that right now! The mere fact that you are using Relay and maintaining a local graph on the client means you are already wildly more efficient than any client application that doesn't use caching (which includes your most recent project, be honest). Focus on using FIELDS_CHANGE for now - it's easy to reason about and understand, because all you need to do is provide a map between the field names on the fat query (e.g. catalog_item) and the IDs for those items in your local graph (e.g. "123").

Once I decided that the non-FIELDS_CHANGE mutation configs were just performance improvements, I found mutations made a lot more sense, and hopefully that helps you, too. Here are a couple examples to prove my point.

Adding a new comment to a post? Instead of using RANGE_ADD to only request the new comment, just refetch the entire comments collection:

getFatQuery() {
  return Relay.QL`
    fragment on AddCommentToPostPayload {
      post {
        comments
      }
    }
  `
}
getConfigs() {
  return [{
    type: "FIELDS_CHANGE",
    fieldIDs: {
      post: this.props.post.id
    }
  }]
}

Change your mind and want to delete that comment? Don't sweat the NODE_DELETE for now, just refetch all the comments again!

getFatQuery() {
  return Relay.QL`
    fragment on DeleteCommentPayload {
      post {
        comments
      }
    }
  `
}
getConfigs() {
  return [{
    type: "FIELDS_CHANGE",
    fieldIDs: {
      post: this.props.post.id
    }
  }]
}

Get the idea? I believe that every mutation can accurately described as a FIELDS_CHANGE mutation because, in the absolute worst case, you can just refetch the entire local graph and be guaranteed to be up to date. Of course that's a bit extreme and I don't actually recommend you do that! Although to be fair, remember that your Relay components only ask for the minimum set of data they need to render, so even in the extreme example of refetching the entire local graph, the resulting GraphQL response might be smaller than some of the REST documents your current app is downloading and only using a handful of fields from...

We don't need to go to the extreme, though. If it feels like you are requesting too much data with FIELDS_CHANGE, or if you can't reasonably express what's changing by naming the parent nodes that are affected, it's time to iterate a bit. Let's briefly touch on that.

#4.5: Define more optimal Configs - i.e. "How can Relay guess about how the actual graph changed and apply that change to the local graph?"

For certain types of mutations, we can use NODE_DELETE, RANGE_ADD, and RANGE_DELETE to "guess" what the server changed in the remote graph. By guessing, we avoid having to refetch as much data - Relay can construct a more minimal mutation query and rely on us, the developer, doing the right thing.

To decide if we can use these more optimal mutation configs, we can follow this handy flow chart:

If you understand mutations well enough to confidently answer those questions, you probably don't need my help understanding how to configure those more advanced types. I'll leave it there for now, and maybe revisit those in a future post.

#5: Define Optimistic Response, if you can - i.e. "Can I fake a server response to make Relay update the local graph immediately?"

Now that you understand what mutations are truly doing - causing a change to a portion of the server graph, then querying the server in order to update the local graph accordingly - we can finish up our mutation definition. You'll note that the client knows a lot about what's happening. It precisely defines what portion of the graph to target (via the input variables), it understands what area of the graph is affected (via the fat query), and it constructs a query to refetch that affected portion (via the configs and the local graph). Since the client knows so much about what the mutation is going to do, it stands to reason that it could simply simulate the mutation on the client and not rely on the server at all, except to permanently record the change! If we don't rely on the server at all, we can make the change instantly, right? That's what optimistic responses give us.

I find it useful to think of optimistic responses as "simulating the mutation" on the client, but it's actually not quite true. Instead of performing the mutation and transforming the local graph manually, all we actually need to do is fake a server response, and Relay will pretend that was the real response. We don't have to obsess about making a perfect response, either - Relay will only use the optimistic response temporarily until the real server response arrives, which makes a permanent change to the local graph. Very cool.

For our example mutation, it's fairly easy to write a server response like so:

getOptimisticResponse() {
  return {
    catalog_item: {
      id: this.props.catalogItem.id, // == "123"
      endorsed_count: this.props.catalogItem.endorsed_count + 1, // === "101"
      is_endorsed_by_current_user: true
    }
  }
}

You might find it helpful to imagine optimistic responses as being the output of a "transform" on an existing fragment. It's often easy to create the response directly like in the example, but I could also have done it like this:

getOptimisticResponse() {
  let simulateEndorseCatalogItemMutation = (catalogItem) => {
    let mutatedCatalogItem = Object.assign({}, catalogItem)
    mutatedCatalogItem.endorsed_count += 1
    mutatedCatalogItem.is_endorsed_by_current_user = true
  }
  return { 
    catalog_item: simulateEndorseCatalogItemMutation(this.props.catalogItem)
  }
}

With our optimistic response defined, we can place some temporary data in the local graph while we wait for the true mutation response to arrive. Since we're updating the local graph, this also triggers a re-render of any components that were using that data, so our UI instantly updates. Here's what that looks like:

Bringing It All Together

At this point, our mutation is usable. We've defined the input variables, given our operation a name, added the fat query, specified a config, and wrote an optimistic response. We can package it all up into a class that extends Relay.Mutation and call it a day.

class EndorseCatalogItemMutation extends Relay.Mutation {
  getMutation() {
    return Relay.QL `mutation { endorse_catalog_item }`
  }

  getVariables() {
    return {
      id: this.props.catalogItem.id
    }
  }

  getFatQuery() {
    return Relay.QL `
      fragment on EndorseCatalogItemMutationPayload {
        catalog_item {
          endorsed_count
          is_endorsed_by_current_user
        }
      }
    `
  }

  getConfigs() {
    return [{
      type: "FIELDS_CHANGE",
      fieldIDs: {
        catalog_item: this.props.catalogItem.id
      }
    }]
  }

  getOptimisticResponse() {
    return {
      catalog_item: {
        id: this.props.catalogItem.id,
        endorsed_count: this.props.catalogItem.endorsed_count + 1,
        is_endorsed_by_current_user: true
      }
    }
  }
}

We can now use this mutation in any of our React components by invoking it's constructor with the right input variables and pass that off to Relay.Store.update(), which will send the mutation to the server, apply the optimistic update, and then wait for the mutation response before making the permanent change to the store. Success!

Refining Our Example

The example provided here is a mutation that affects a single node, so everything ends up being relatively simple. In reality, even small mutations like this one tend to affect a larger portion of the graph than I've shown here. For example, this action (endorsing a CatalogItem) definitely affects the catalog_item node, but it could also affect a total_endorsement_count field on the user who originally shared that catalog_item, or add a new edge to the current user's endorsed_catalog_items connection. In both cases, we'd need to update the fat query (to specify the other nodes that are affected) and provide additional fieldIDs to our FIELDS_CHANGE config. Hopefully by now you can imagine how that might work, but here's a theoretical example to help illustrate that:

getFatQuery() {
  return Relay.QL `
    fragment on EndorseCatalogItemMutationPayload {
      catalog_item {
        endorsed_count
        is_endorsed_by_current_user
      }
      sharer {
        total_endorsement_count
      }
      current_user {
        endorsed_catalog_items
      }
    }
  `
}

getConfigs() {
  return [{
    type: "FIELDS_CHANGE",
    fieldIDs: {
      catalog_item: this.props.catalogItem.id,
      sharer: this.props.catalogItem.sharer.id,
      current_user: this.props.currentUser.id
    }
  }]
}

As your mutations get more complex, they will start to have data dependencies of their own, so you'll also want to define fragments on your mutation to fetch that data before the mutation is even constructed (via the beauty of Relay container composition, of course!).

Summary

The key to understanding Relay mutations is to start "thinking with graphs". This was my biggest stumbling block when getting started, since I came from a REST world where mutations were always custom endpoints with custom input data and custom responses. The "Relay way" involves expressing your mutations as graph operations; if you do that, everything else starts to fall in place.

To get started, you can follow this five (and a half) step guide to design all your mutations:

#1: Define Input Variables - i.e. "What portion of the graph are we targeting?"
#2: Define Name - i.e. "What should we call this operation to make it clear?"
#3: Define Fat Query - i.e. "What portions of the graph does this mutation affect?"
#4: Define Configs using FIELDS_CHANGE - i.e. "How should Relay intersect the fat query against your local graph to construct the mutation query?"
#4.5: Define more optimal Configs - i.e. "How can Relay guess about how the actual graph changed and apply that change to the local graph?"
#5: Define Optimistic Response, if you can - i.e. "Can I fake a server response to make Relay update the local graph immediately?"

Once you become more familiar with Relay, you'll get more confident about moving outside the boundaries. You'll also start to notice some of the idiosyncrasies that don't quite make sense yet. For example, why are fat queries a client-side concern? How can you accurately describe a graph operation from the client when you don't actually know what exactly which nodes in the graph will be affected ahead of time? Not all these questions have great answers... yet. Do you think you have one? Submit a PR: https://github.com/facebook/relay

Reach Out

If you have any questions about this stuff, feel free to comment here, tweet at me (@nsamuell), drop me a question on the #relay channel on Reactiflux, or just send me a good ol' fashioned email: neville@pathgather.com. Special thanks to the Reactiflux folks who helped me learn this stuff and edit this post, too: @eyston, @ryancole, and @KyleAMathews!

Keep learning!

 

 

 


* there's also the secret "REQUIRED_CHILDREN" config you can use to add whatever fields you want to the mutation query. To find out more about this mysterious type, hunt it down in the Relay source :)

Mobile Analytics