A deep-dive into Relay, the friendly & opinionated GraphQL client

Subscribe to the Hasura Newsletter

πŸš€Hasura now has Relay support and we'd love your feedback! Get an auto-generated Relay backend on top of your database.

A relay network is a broad class of network topology commonly used in wireless networks, where the source and destination are interconnected by means of some nodes. - Wikipedia

Relay is a JavaScript framework for declaratively fetching and managing GraphQL data. "Sounds approachable enough," you say? Not so fast. Relay is an amazing friend once you win over its heart, but it doesn't bond with just anybody. There are strict conventions you must follow in order to become worthy in its eyes.

The most impressive fact about Relay (besides having eyes) is that no matter how hard you try, IT WON'T LET YOU FAIL. With its strict conventions, it removes opportunities for developer errors at every turn. By standardizing everything, it gives you no choice but to write performant and type-safe apps.

πŸ’‘ Facebook originally created GraphQL and Relay in the context of trying to improve UX for very slow network connections.

πŸ’‘ The examples in this article are based on the experimental release of Relay Hooks, a new Relay API that supports React Concurrent Mode and Suspense.

TLDR: Is Relay for me?

βœ… Yes, if you'd like an opinionated, robust framework that will keep your app sturdy and performant as it increases in size and complexity.

❌ No, if you'd like freedom to follow your own path. Go with a less opinionated, more flexible GraphQL client like Apollo.



It's all well and good to have opinions, but how does Relay enforce them?

Relay is made up of three libraries:

  • Relay compiler: The ⭐ of the show. Analyzes any GraphQL inside your code during build time, and validates, transforms, and optimizes it for runtime.
  • Relay runtime: A core runtime that is React-agnostic.
  • React/Relay: A React integration layer.

You see, if there's anything wrong with your code, the Relay compiler won't pull any punches. It will tell you without hesitation that it simply refuses to build it. Your poor broken code won't even get to run locally, let alone in production.

What are these conventions you speak of?

Relay not only has a strict GraphQL server specification (which we'll explore in a bit), but also many conventions on the client side. The most famous one of these is colocation, where data definitions (what fields we need on a GraphQL type), and view definitions (how to display this data) live happily together inside each component.

But how do we split the data definitions into separate components?

Do you mean, how do we split them into fragments? Why, with GraphQL fragments, of course! Everything in Relay revolves around fragments. A fragment is a selection of fields on a GraphQL type.

fragment AlbumFragment on Album {
  name
  genre
  tracks {
    name
  }
}

Fragments help you write DRY queries.

{
  artist(name: "The Muppets") {
    id
    albums {
      image_url
      ...AlbumFragment
    }
    related_artists {
      name
      albums {
        release_date
        ...AlbumFragment
      }
    }
  }
}

In Relay, fragments allow you to colocate data and view definitions.

function AlbumDetail(props) {
  const data = useFragment(graphql`
    fragment AlbumDetail_album on Album {
      genre      
      label
    }
  `, props.album);

  return (
    <Genre>{data.genre}</Genre>
    <Label>{data.label}</Label>
  )
}

Relay composes fragments from multiple components into optimized and efficient batches to reduce round-trips to the server.

With this structure:

  • It's hard to over-fetch or under-fetch data, because each component declares exactly the data it needs. Relay defaults to the performant and defensive pattern.
  • Components can only access data they've asked for. This data masking prevents implicit data dependency bugs: each component must declare its own data requirements without relying on others.
  • Components only re-render when the exact data they're using is updated, preventing unnecessary re-renders.

In short, colocating data and view definitions makes your components modular, easier to refactor, more performant, and less error-prone.

By the way, did you notice the fragment name, AlbumDetail_album? Relay requires globally unique fragment names. The convention is <module_name>_<property_name>. The property_name part avoids naming collisions when you define multiple fragments in a single module.

Also notice the useFragment hook. It takes a fragment definition and fragment reference (props.album), and returns the corresponding data. The fragment reference is the object that contains the fragment in the root query, passed into the component as a prop from the parent component.

A fragment reference is like a pointer to a specific instance of a type that we want to read data from. - Relay docs

The root query is necessary, because fragments can't be fetched on their own. They must be included ("rooted") in a query inside a parent component. That said, the parent can also be a fragment component, as long as the root exists somewhere up the tree. We'll see the root query in a little bit.

Sounds tight. But can you really be defensive without strong typing?

Indeed, you can't! That's why Relay is thoroughly type-safe. Automatic type generation is only one of the cool things that the Relay compiler does. When you run the compiler, it takes any declared fragments in your code, and generates Flow types from them (or TypeScript, if you choose). You can then declare the type for your component's props thusly:

import type {AlbumDetail_album$key} from './__generated__/AlbumDetail_album.graphql';

type Props = {| // Flow exact object type: no additional properties allowed
  album: AlbumDetail_album$key,
|};

function AlbumDetail(props: Props) {
  const data = useFragment(graphql`
    fragment AlbumDetail_album on Album {
      genre      
      label
    }
  `, props.album);

  return (
    <Genre>{data.genre}</Genre>
    <Label>{data.label}</Label>
  )
}

Of note here:

  • The $key suffix denotes a type for the fragment reference, while the $data suffix denotes a type for the shape of the data.
  • Because our fragment reference (props.album) is properly typed, the returning data will be automatically Flow typed: {| genre: ?string, label: ?string} |}

In addition to validation (checking queries for errors and injecting type information), the Relay compiler also applies a bunch of transforms to optimize your queries by removing redundancies. This reduces query payload size, which leads to... Hey, what was that one thing that Relay was always working towards?... Oh right, better performance! πŸ’ƒπŸ•Ί Here's a neat REPL that demos some of these transforms.

Relay also supports persisted queries with the --persist-output flag. When enabled, it converts your GraphQL operations into md5 hashes. Not only does this reduce query payload size even more, but your server can now allow-list queries, meaning clients are restricted to a specific set of queries, improving security.

Back to the root query... What does it look like?

Speaking of being obsessed with performance, Relay offers different ways to fetch and render queries. Let's first look at the less efficient, but more direct way, using the Β useLazyLoadQuery hook.

import React from "react";
import { graphql, useLazyLoadQuery} from "react-relay/hooks";
import type { AlbumQuery } from './__generated__/AlbumQuery.graphql';
import AlbumDetail from './AlbumDetail';

function AlbumRoot() {
  const data = useLazyLoadQuery<AlbumQuery>(
    graphql`
      query AlbumQuery($id: ID!) {
        album(id: $id) {
          name

          # Include child fragment:
          ...AlbumDetail_album
        }
      }
    `,
    {id: '4'},
  );

  return (
    <>
      <h1>{data.album?.name}</h1>
      {/* Render child component, passing the fragment reference: */}
      <AlbumDetail album={data.album} />
    </>
  );
}

export default AlbumRoot;

We call useLazyLoadQuery inside our component (i.e. after our component loads β€” note this for later), passing in the query and variables, both checked by Flow against the auto-generated AlbumQuery type. We include the child fragment AlbumDetail_album in the query. The returned data is also automatically Flow typed, just like we saw with fragments earlier. Finally, we pass the fragment reference data.album into the child component AlbumDetail.

The data obtained as a result of useLazyLoadQuery also serves as the fragment reference for any child fragments included in that query. - Relay docs

Note that if we were at the very root of our app, we'd wrap everything with a RelayEnvironmentProvider, passing via the context an environment object, which would be configured with a network (fetch function with our GraphQL endpoint) and store (the local Relay cache). See examples here and here.

What if I wanted to be less lazy?

If lazy loading queries is not your jam, Relay offers you a way to fetch data more proactively. With usePreloadedQuery, we can start fetching data as soon as there's a user interaction like hovering over or clicking a link. And with Suspense, we can have a fallback UI to show while the data is loading.

import React, { Suspense } from "react";
import {
  preloadQuery,
  usePreloadedQuery,
} from "react-relay/hooks";
import type { AlbumQuery } from './__generated__/AlbumQuery.graphql';
import RelayEnvironment from './RelayEnvironment';
import AlbumDetail from './AlbumDetail'; 

// Immediately load the query on user interaction. For example,
// we can include this in our routing configuration, preloading
// data as we transition to new routes, e.g. path: '/album/:id'

// See example here: 
// https://github.com/relayjs/relay-examples/blob/master/issue-tracker/src/routes.js

const preloadedQuery = preloadQuery(RelayEnvironment, AlbumQuery, {id: '4'});

function AlbumRoot(props) {

  // Define what data the component needs, with `usePreloadedQuery`.
  // We're not fetching data here; we already fetched it
  // with `preloadQuery` above.
  // The result is passed in via props.
  
  const data = usePreloadedQuery<AlbumQuery>(
    graphql`
      query AlbumQuery($id: ID!) {
        album(id: $id) {
          name
          ...AlbumDetail_album
        }
      }
    `, props.preloadedQuery);

  return (
    <Suspense fallback={"Loading..."}>
      <h1>{data.album?.name}</h1>
      <AlbumDetail album={data.album} />
    </Suspense>
  );
}

export default AlbumRoot;

We start fetching data as soon as possible with preloadQuery, even before the component loads. Then, inside the component, we render the data as it becomes available, with usePreloadedQuery. You can wrap any part of your component with a Suspense block to have granular control over what is rendered while you wait for data. This way, you can show some data without waiting for all of it to be ready.

Suspense will render the provided fallback until all its descendants become "ready" (i.e. until all of the promises thrown inside its subtree of descendants resolve). - Relay docs

What if there's an error?

In case of an error, you can wrap your components in an ErrorBoundary that you define. See examples here and here. Error boundaries were introduced in React 16 as components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI.

Note that unlike Apollo Client, which gives you error handling options, Relay ignores GraphQL errors unless:

  • the fetch function provided to the Relay Network throws or returns an Error
  • the top-level data field wasn't returned in the response

To access GraphQL errors in your UI, the recommended practice is to model errors in your GraphQL schema.

type Error {
  # User-friendly message
  message: String!
}

type Album {
  image: Result | Error
}

See this article for an excellent breakdown of how you might model errors this way. And for more on nulls and errors in GraphQL, check out this cheatsheet.

How can I access variables in fragments?

Let's say you have an album image that's available in various sizes. In Relay, you can declare local variables for fragments using the @arguments and @argumentDefinitions directives.

# Declare fragment that accepts arguments
fragment AlbumDetail_album on Album
  @argumentDefinitions(scale: { type: "Float!", defaultValue: 2 }) {
  image(scale: $scale) {
    uri
  }
}

# Include fragment and pass in arguments
query AlbumQuery($id: ID!) {
  album(id: $id) {
    name
    ...AlbumDetail_album @arguments(scale: 1)
  }
}

☝️ That's a pretty nice superpower! πŸ¦Ήβ€β™€οΈ

I want to write a Relay-compatible GraphQL server

Relay makes three core assumptions about a GraphQL server.

1. A mechanism for refetching an object

"The server must provide an interface called Node. That interface must include exactly one field, called id that returns a non-null ID. This id should be a globally unique identifier for this object, and given just this id, the server should be able to refetch the object." - GraphQL Docs
interface Node {
  id: ID!
}
The server must provide a root field called node that returns the Node interface. This root field must take exactly one argument, a non-null ID named id. - GraphQL Docs
type Album implements Node {
  id: ID!
  name: String!
}

{
  node(id: "4") {
    id
    ... on Album {
      name
    }
  }
}

Using the Node interface and globally unique ids, Relay is able to accomplish amazing feats of performance when it comes to fetching data from the cache or the network. For more on this, check out this excellent breakdown by Gabriel Nordeborn and Sean Grove.

2. A description of how to page through connections

Relay uses the GraphQL Cursor Connections Specification to standardize pagination. This saves a ton of work on the client side when it comes to managing cursors, merging results, keeping track of loading state, etc, in that you don't need to manually do any of that work. As long as you follow the standard, Relay takes care of the plumbing for you behind the scenes.

In the query, the connection model provides a standard mechanism for slicing and paginating the result set. In the response, the connection model provides a standard way of providing cursors, and a way of telling the client when more results are available. - Relay spec

By introducing layers of indirection such as "edges" and "connections," Relay is able to communicate more information between the client and the server in a predictable way.

{
  artist {
    name
    albums(first: 2) {
      totalCount
      edges {
        node {
          name
        }
        cursor
      }
      pageInfo {
        endCursor
        hasNextPage
      }
    }
  }
}

The connection has info on itself such as totalCount, and pageInfo such as endCursor and hasNextPage. The edge has info on the node itself, such as its cursor.

For more on pagination in Relay and how it automates everything for you, check out this article in the same series by Gabriel Nordeborn and Sean Grove.

3. Structure around mutations to make them predictable*

Mutation arguments must be wrapped in an input type object.

By convention, mutations are named as verbs, their inputs are the name with "Input" appended at the end, and they return an object that is the name with "Payload" appended. - Relay docs

*Although this convention is in the Relay docs, it's not enforced.

input AddAlbumInput {
  artistId: ID!
  albumName: String!
}

type AddAlbumPayload {
  artist: Artist
  album: Album
}

mutation AddAlbumMutation($input: AddAlbumInput!) {
  addAlbum(input: $input) {
    album {
      id
      name
    }
    artist {
      name
    }
  }
}

πŸš€With Hasura's Relay support, you get an auto-generated Relay backend, which sets up the above spec for you automatically.

Conclusion

That's it for our whirlwind tour of Relay! We covered:

  • How Relay gives you no choice but to build performant, type-safe apps by following strict conventions
  • How everything in Relay revolves around fragments & colocation
  • How the Relay compiler watches your back with automatic type generation and query validation, and saves you upload bytes with transforms and persisted queries
  • Query best practices for even MOAR performant apps
  • How to handle loading and error states
  • How to pass arguments to fragments
  • How to write a Relay-compatible GraphQL server

That's A LOT of new concepts to grok! Cheers for making it to the end 🍻


When I asked my friends about their experience with Relay, these were their pros & cons:

Dev Favorites

Dev Complaints

  • "Fragment drilling" for deep trees is gnarly (similar to "prop drilling")
  • Testing is confusing, and generation of mock data is weird
  • Needs a lot of tooling
  • Hard to debug GraphQL

What about you? What has been your experience with Relay? Share with us on Twitter or in the comments below!


Hasura makes your data instantly accessible over a real-time GraphQL API, so you can build and ship modern apps and APIs faster. Hasura connects to your databases, REST servers, GraphQL servers and third party APIs (eg: Stripe) to provide a unified realtime GraphQL API across all your data sources instantly.


Subscribe to the Hasura Newsletter

PS: We’re hiring!


Sezgi Ulucam

Sezgi Ulucam

Developer Advocate πŸ₯‘ @ Hasura | Front-End Dev | Baker | Dancer | Massage Therapist | WFR

Read More