A deep-dive into Relay, the friendly & opinionated GraphQL client
🚀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
💡 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?
It's all well and good to have opinions, but how does Relay enforce them?
- 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.
What are these conventions you speak of?
But how do we split the data definitions into separate components?
fragment AlbumFragment on Album {
name
genre
tracks {
name
}
}
{
artist(name: "The Muppets") {
id
albums {
image_url
...AlbumFragment
}
related_artists {
name
albums {
release_date
...AlbumFragment
}
}
}
}
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>
)
}
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.
A fragment reference is like a pointer to a specific instance of a type that we want to read data from. - Relay docs
Sounds tight. But can you really be defensive without strong typing?
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>
)
}
- 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 returningdata
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?
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;
The data obtained as a result of useLazyLoadQuery
also serves as the fragment reference for any child fragments included in that query. - Relay docs
What if I wanted to be less lazy?
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;
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?
- the fetch function provided to the Relay
Network
throws or returns an Error - the top-level
data
field wasn't returned in the response
type Error {
# User-friendly message
message: String!
}
type Album {
image: Result | Error
}
How can I access variables in fragments?
# 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)
}
}
I want to write a Relay-compatible GraphQL server
1. A mechanism for refetching an object
"The server must provide an interface calledNode
. That interface must include exactly one field, calledid
that returns a non-null ID. Thisid
should be a globally unique identifier for this object, and given just thisid
, the server should be able to refetch the object." - GraphQL Docs
interface Node {
id: ID!
}
The server must provide a root field callednode
that returns theNode
interface. This root field must take exactly one argument, a non-null ID namedid
. - GraphQL Docs
type Album implements Node {
id: ID!
name: String!
}
{
node(id: "4") {
id
... on Album {
name
}
}
}
2. A description of how to page through connections
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
{
artist {
name
albums(first: 2) {
totalCount
edges {
node {
name
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
3. Structure around mutations to make them predictable*
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
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
- 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
Dev Favorites
- Local state management
- Strong typing everywhere
- Excellent tooling
- Data dependency colocation
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
Related reading