DEV Community

loading...
Cover image for relay client

relay client

ajcwebdev profile image anthonyCampolo ・5 min read

In a resource-oriented REST system, we can maintain a response cache based on URIs:

var _cache = new Map()
rest.get = uri => {
  if (!_cache.has(uri)) {
    _cache.set(uri, fetch(uri))
  }
  return _cache.get(uri)
}
Enter fullscreen mode Exit fullscreen mode

Response-caching can also be applied to GraphQL. The text of the query itself can be used as a cache key.

var _cache = new Map()
graphql.get = queryText => {
  if (!_cache.has(queryText)) {
    _cache.set(queryText, fetchGraphQL(queryText))
  }
  return _cache.get(queryText)
}
Enter fullscreen mode Exit fullscreen mode

Requests for previously cached data can now be answered immediately without making a network request. This is a practical approach to improving the perceived performance of an application but can cause problems with data consistency.

Cache Consistency

With GraphQL it is very common for the results of multiple queries to overlap. Our response cache doesn't account for this overlap because it caches based on distinct queries.

If we issue a query to fetch stories and then later refetch one of the stories whose likeCount has changed we'll see different likeCounts depending on how it is accessed.

query { stories { id, text, likeCount } }

query { story(id: "123") { id, text, likeCount } }
Enter fullscreen mode Exit fullscreen mode

A view that uses the first query will see an outdated count, while a view using the second query will see the updated count.

Caching A Graph

A solution to caching GraphQL is to normalize the hierarchical response into a flat collection of records. Relay implements this cache as a map from IDs to records. Each record is a map from field names to field values. Records may also link to other records (allowing it to describe a cyclic graph), and these links are stored as a special value type that references back into the top-level map.

With this approach each server record is stored once regardless of how it is fetched. Here's a query that fetches a story's text and its author's name.

query {
  story(id: "1") {
    text,
    author {
      name
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And here's a possible response:

query: {
  story: {
     text: "Relay is open-source!",
     author: {
       name: "Jan"
     }
  }
}
Enter fullscreen mode Exit fullscreen mode

The response is hierarchical but we'll cache it by flattening all the records. While this works for simple applications in reality the cache usually needs to handle one-to-many associations and pagination among other things.

Map {
  1: Map {
    text: 'Relay is open-source!',
    author: Link(2),
  },
  2: Map {
    name: 'Jan',
  },
}
Enter fullscreen mode Exit fullscreen mode

Using The Cache

We'll look at two operations:

  • Writing to the cache when a response is received
  • Reading from the cache to determine if a query can be fulfilled locally (the equivalent to _cache.has(key) but for a graph)

Populating The Cache

Populating the cache involves walking a hierarchical GraphQL response and creating or updating normalized cache records. At first it may seem that the response alone is sufficient to process the response, but in fact this is only true for very simple queries.

Consider user(id: "456") { photo(size: 32) { uri } } — how should we store photo? Using photo as the field name in the cache won't work because a different query might fetch the same field but with different argument values (e.g. photo(size: 64) {...}).

With pagination if we fetch the 11th to 20th stories with stories(first: 10, offset: 10), these new results should be appended to the existing list.

Therefore, a normalized response cache for GraphQL requires processing payloads and queries in parallel. For example, the photo field from above might be cached with a generated field name such as photo_size(32) in order to uniquely identify the field and its argument values.

Reading From Cache

To read from the cache we can walk a query and resolve each field. But wait: that sounds exactly like what a GraphQL server does when it processes a query. And it is! Reading from the cache is a special case of an executor where:

  • There's no need for user-defined field functions because all results come from a fixed data structure
  • Results are always synchronous — we either have the data cached or we don't.

Relay implements several variations of query traversal. These are operations that walk a query alongside some other data such as the cache or a response payload.

When a query is fetched Relay performs a "diff" traversal to determine what fields are missing. This can reduce the amount of data fetched in many common cases. It also allows Relay to avoid network requests entirely when queries are fully cached.

Cache Updates

This normalized cache structure allows overlapping results to be cached without duplication. Each record is stored once regardless of how it is fetched.

We can see how this cache helps in our earlier example of inconsistent data. The first query was for a list of stories:

query { stories { id, text, likeCount } }
Enter fullscreen mode Exit fullscreen mode

With a normalized response cache, a record would be created for each story in the list. The stories field would store links to each of these records. The second query refetched the information for one of those stories:

query { story(id: "123") { id, text, likeCount } }
Enter fullscreen mode Exit fullscreen mode

Relay can detect the result overlaps with existing data based on its id when the response is normalized. Relay will update the existing 123 record rather than creating a new record. The new likeCount is available to both queries, as well as any other query that might reference this story.

Data/View Consistency

A normalized cache ensures the cache is consistent but not the views. Ideally, our React views would always reflect the current information from the cache. Consider rendering the text and comments of a story along with the corresponding author names and photos.

query {
  story(id: "1") {
    text,
    author { name, photo },
    comments {
      text,
      author { name, photo }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

After initially fetching this story our cache might have the story and comment both linking to the same record as author.

Map {
  1: Map {
    text: 'got GraphQL?',
    author: Link(2),
    comments: [Link(3)],
  },
  2: Map {
    name: 'Yuzhi',
    photo: 'http://.../photo1.jpg',
  },
  3: Map {
    text: 'Here\'s how to get one!',
    author: Link(2),
  },
}
Enter fullscreen mode Exit fullscreen mode

The author of this story also commented on it. Now imagine that some other view fetches new information about the author, and their profile photo has changed to a new URI. Here's the only part of our cached data that changes.

Map {
  ...
  2: Map {
    ...
    photo: 'http://.../photo2.jpg',
  },
}
Enter fullscreen mode Exit fullscreen mode

The value of the photo field has changed so the record 2 has also changed. Nothing else in the cache is affected, but clearly our view needs to reflect the update. Both instances of the author in the UI (as story author and comment author) need to show the new photo.

Immutable data structures are a common proposed solution for this.

ImmutableMap {
  1: ImmutableMap {/* same as before */}
  2: ImmutableMap {
    ... // other fields unchanged
    photo: 'http://.../photo2.jpg',
  },
  3: ImmutableMap {/* same as before */}
}
Enter fullscreen mode Exit fullscreen mode

Replacing 2 with a new immutable record will get a new immutable instance of the cache object even though records 1 and 3 are untouched. We can't tell that story's contents have changed just by looking at the story record alone because the data is normalized.

Achieving View Consistency

Relay maintains a mapping from each UI view to the set of IDs it references. The story view would subscribe to updates on:

  • The story (1)
  • The author (2)
  • The comments (3 and any others)

When writing data into the cache, Relay tracks which IDs are affected and notifies only the views that are subscribed to those IDs. The affected views re-render, and unaffected views opt-out of re-rendering for better performance with shouldComponentUpdate.

This also works for writes. Any update to the cache will notify the affected views, and writes are just another thing that updates the cache.

Discussion

pic
Editor guide