Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Leveraging GraphQL interfaces to build reusable components with Relay #6

Open
zth opened this issue May 23, 2020 · 0 comments
Open
Labels

Comments

@zth
Copy link
Contributor

zth commented May 23, 2020

A Lego block attached to a Lego surface

Interfaces in GraphQL can be really powerful if used right. In this article, I'll try to illustrate that by showing how you can leverage them to design a schema that'll let you build truly reusable components on the frontend. Components that can save you a lot of time and friction down the road.

Throughout this article we'll build a tiny, reusable component that'll allow us to star and unstar anything that can be starred through the GitHub API. Here's a mock of the simple thing we're building:

An SVG star that's highlighted depending on if it has been clicked or not

Multiple things can be starred in the GitHub API. I'll show that you can write a component that can be re-used with any of the individual types that can be starred, without needing to care about what underlying type it is.

We'll use Relay, Facebook's client side GraphQL framework. Don't know about Relay, or curious to read more about it? Check out this article covering what Relay is and why you should care about it.

But, before building, let's do a brief overview of what an interface actually is in GraphQL.

Interfaces guarantees that certain fields exists

In GraphQL, interfaces are a way of adding constraints to object types. An interface defines a set of fields that a GraphQL object type implementing it must have, including their types and arguments. Just to illustrate, the definition and use of an interface can look something like this:

interface HasAnID {
  id: ID!
}

type User implements HasAnID {
  id: ID! # Must exist because this type implements HasAnID, which defines that field
  name: String # A regular field on the User type, independent of any interface
}

type Item implements HasAnID {
  id: ID! # Must exist because this type implements HasAnID, which defines that field
  lastUpdated: Datetime # A field directly on Item
}

The code above defines an interface HasAnID, and then has two object types implement it.

Interfaces can also be used as a return value for a field. Example:

# We're adding a field on the root query that takes an `id` and returns the interface `HasAnID`
type Query {
  # Returning an interface type like below means that any of the types that implement the 
  # HasAnID interface could be returned. `User` | `Item` in this particular example.
  hasAnID(id: ID!): HasAnID
}

Querying that field will allow you to ask for the fields that interface implements without needing to know what type is actually returned:

query {
  hasAnID(id: "some-id") {
    # I can select this without knowing whether the returned type is a `User` or `Item`, because
    # the interface guarantees `id` always exists.
    id

   # If I want _specific_ things from any of the underlying types, I can select that via an inline 
   # fragment spread too, and it'll be given to me if the type matches at runtime.
   ... on User {
     name
   }
  }
}

Let's sum up what the code above tells us:

  • Multiple types implement an interface. That guarantees that each of those types has at least the exact fields that interface defines present in one, uniform way that we can rely on when writing code.
  • Returning an interface type from a field in GraphQL means that any of the types implementing the interface could be returned for that field.
  • Since the interface guarantees that certain fields will always exist, you can select those common fields of the interface without needing to care about what actual type it is that's returned.
  • If you need specific fields from specific types depending on what's returned at runtime, you can select that too easily.

Real world example: Starrable in the GitHub API

Let's look at a real world example from the GitHub API schema. This interface below defines everything a type needs to have to be Starrable (meaning you can star and unstar it):

"""Things that can be starred."""
interface Starrable {
  id: ID!

  """A list of users who have starred this starrable."""
  stargazers(
    """Returns the elements in the list that come after the specified cursor."""
    after: String

    """
    Returns the elements in the list that come before the specified cursor.
    """
    before: String

    """Returns the first _n_ elements from the list."""
    first: Int

    """Returns the last _n_ elements from the list."""
    last: Int

    """Order for connection"""
    orderBy: StarOrder
  ): StargazerConnection!

  """
  Returns a boolean indicating whether the viewing user has starred this starrable.
  """
  viewerHasStarred: Boolean!
}

The definition above means that any type that implements Starrable must:

  1. Have an id field
  2. Have a field called stargazers (that paginates a list of those who've starred that thing)
  3. Have a field called viewerHasStarred that indicates whether the viewer (read: logged in user) has starred

As you can see, an interface is all about enforcing constraints so you can make assumptions about the data you get back without needing to know all the details.

Real world example: Implementing an interface on a GraphQL type

Let's have a look at the Repository type in the GitHub API, which implements Starrable:

"""A repository contains the content for a project."""
type Repository implements Node & ProjectOwner & RegistryPackageOwner & RegistryPackageSearch & Subscribable & Starrable & UniformResourceLocatable & RepositoryInfo {
  """The name of the repository."""
  name: String!

  """Identifies if the repository is a fork."""
  isFork: Boolean!

  """The User owner of the repository."""
  owner: RepositoryOwner!

  """Identifies when the repository was last pushed to."""
  pushedAt: DateTime

  """A list of users who have starred this starrable."""
  stargazers(
    """Returns the elements in the list that come after the specified cursor."""
    after: String

    """
    Returns the elements in the list that come before the specified cursor.
    """
    before: String

    """Returns the first _n_ elements from the list."""
    first: Int

    """Returns the last _n_ elements from the list."""
    last: Int

    """Order for connection"""
    orderBy: StarOrder
  ): StargazerConnection!

  """
  Returns a boolean indicating whether the viewing user has starred this starrable.
  """
  viewerHasStarred: Boolean!
}

I've omitted a lot of the type definition since Repository has quite a lot of fields.

Ignore the fact that Repository implements a lot of interfaces, and focus on that it implements Starrable. Notice that fields specific to Repository, like name and isFork, are present, as well as all the fields from Starrable.

This sums up the way interfaces work - a type implementing an interface guarantees that at least the fields of the interface are present.

So... why care?

This in itself isn't that exciting.

It's nice and all that we can enforce that certain fields exist in a coherent way over multiple types, and writing type SomeType implements SomeInterface does feel pretty fancy. But we need to duplicate the definition of the fields anyway (adding implements SomeInterface does not make the fields defined by that interface magically appear on the type, you still need to add them manually), so why bother?

Well, the real power of interfaces comes from making use of them when building components on the frontend.

Let's continue exploring interfaces, but now using Relay, Facebook's client side framework for building GraphQL applications.

Unfamiliar with Relay? Check out this article introducing Relay.

Fragments are great, and you can define them on interfaces

Fragments are a core concept in GraphQL, and Relay makes heavy use of them. A GraphQL fragment is basically a reusable selection of fields on a GraphQL type. A typical definition and usage of a fragment looks like this:

# This fragment can be included on any User
fragment Avatar_user on User {
  name
  avatarUrl
}

# When making a query, I can just include my fragment instead of typing out the fields manually.
# This is great because if I find out I need to add (or remove) fields for rendering my Avatar, I can
# just change that in one central place, and I'm guaranteed it's changed everywhere that fragment
# is used.
query {
  loggedInUser {
    ...Avatar_user # This will select name and avatarUrl
    friends {
       ...Avatar_user # Nice, I don't have to re-type the fields
    }
  }
}

There's a lot to say about fragments (and especially about how Relay leverages them). Long story short - for this article, all you need to know about fragments is that:

  • A fragment is a way to define a reusable selection of fields on a GraphQL type
  • Relay makes heavy use of fragments and you typically use them a lot with Relay
  • Fragments can be defined on interfaces. It allows you to select any fields of that interface and additional fields on any type that implements it.

That last point has a lot of implications, but don't worry if it doesn't make much sense yet. We'll talk a lot more about it soon.

But first, let's have a look at an aspect of interfaces in GraphQL that's really powerful - leveraging them when designing mutations.

Fragments (and fragments in Relay) is worthy of an article all by itself, and I encourage you to read up on it here if you're interested.

Combine the idea of interfaces with mutations for some serious power

Looking a bit deeper into the GitHub schema, we find two mutations that are of particular interest to us:

"""Adds a star to a Starrable."""
  addStar(input: AddStarInput!): AddStarPayload

"""Removes a star from a Starrable."""
  removeStar(input: RemoveStarInput!): RemoveStarPayload

Mutations for both starring and unstarring any type that's Starrable! Checking out the input for each of those mutations, we indeed see that both mutations wants an ID from any type that's Starrable:

input AddStarInput {
  """The Starrable ID to star."""
  starrableId: ID!

  """A unique identifier for the client performing the mutation."""
  clientMutationId: String
}

input RemoveStarInput {
  """The Starrable ID to star."""
  starrableId: ID!

  """A unique identifier for the client performing the mutation."""
  clientMutationId: String
}

These mutations and their unified inputs have clearly been constructed with the ideas of interfaces fresh in mind.

There are currently 3 types that are Starrable in the GitHub API: Repository, Topic and Gist. The API could've easily had variations for starring and unstarring each of them specifically - addRepositoryStar, addTopicStar, addGistStar and so on. After all, they're probably separate entities in the underlying database.

Instead, GitHub has opted to provide a single, unified mutation for both adding and removing stars for anything that's starrable. And that's communicated via the Starrable interface. Smart!

Note that interfaces do not actually exist for mutations in GraphQL. What's described above is just about re-using the ideas of interfaces and bringing what would've been multiple mutations together into one. But, there's naturally more to making this particular thing work than just implementing the interface.

The interface gives us valuable information

This is particularly interesting to us, because it means that if you can get a hold of the id from any type that implements Starrable, you'll be able to star and unstar that thing without knowing what actual type it is. It could be a Repository, or any of the other types in the GitHub schema that're starrable - you don't need to know or care.

Combining this with Relay, we'll be able to build a fully reusable component that can be used with anything that's Starrable, without needing to dive into any specifics about the underlying type. Sweet!

First iteration: Showing whether you've starred the starrable thing or not

Since we're so agile, let's start by build the first, minimal version of our component before jumping into mutations. This iteration will just show whether you've starred a starrable thing or not.

// Star.jsx
import { useFragment } from "react-relay/hooks";

export const Star = ({ starrable }) => {
  const { viewerHasStarred } = useFragment(
    graphql`
       # Defining our fragment on the interface Starrable
       fragment Star_starrable on Starrable {
         # We can select any of the common fields from the interface here
         # without needing to specify what the underlying type is.
         id
         viewerHasStarred
       }
    `,
    starrable
  );

  return (
    <SvgStar 
      filled={viewerHasStarred} 
      label={viewerHasStarred ? "You've starred this." : "Star this!"} 
    />
  );
};

This is the way a typical Relay component looks - it defines one or more GraphQL fragments that describes what data it needs to render, on what GraphQL type. Relay makes heavy use of GraphQL fragments for composability and query efficiency. Read more about fragments in Relay here.

Ok, so this is simple enough. This component is just showing an SVG star that indicates whether you've starred this particular thing or not.

But, just by doing this, we've already accomplished one really nice thing; since the fragment is defined on the interface Starrable, we can drop this component right on any GraphQL type that implements that interface.

The same component for all starrable things, fully reusable. It doesn't matter if you drop it on a Repository, Topic or Gist - it'll work the same with no changes required. Here's a contrived example to illustrate that point, using our Star_starrable fragment in a query fetching multiple distinct types:

query GetRepositoryAndTopic {
  repository(name: "reason-relay", owner: "zth") {
    ...Star_starrable
  }
  
  topic(name: "some-topic") {
    ...Star_starrable
  }
}

This query is fetching two distinct types - repository will return a Repository, and topic will return a Topic. But, since both Repository and Topic implements the Starrable interface, we can drop our Star_starrable fragment from our <Star />-component on both, allowing us to use the <Star />-component ignoring which specific type we're on.

Second iteration: Adding a way to star and unstar

Let's make it possible to star and unstar through this component too, using the mutations leveraging the Starrable interface that we discovered before. Here's the full code:

// Star.jsx
import { useFragment, useMutation } from "react-relay/hooks";

export const Star = ({ starrable }) => {
  const { viewerHasStarred, id } = useFragment(
    graphql`
      fragment Star_starrable on Starrable {
        id
        viewerHasStarred
      }
    `,
    starrable
  );

  const [star] = useMutation(
    graphql`
      mutation Star_AddStarMutation($starrableId: ID!) {
        addStar(input: { clientMutationId: "", starrableId: $starrableId }) {
          starrable {
  
            ...Star_starrable
          }
        }
      }
    `
  );

  const [unstar] = useMutation(
    graphql`
      mutation Star_RemoveStarMutation($starrableId: ID!) {
        removeStar(input: { clientMutationId: "", starrableId: $starrableId }) {
          starrable {
            ...Star_starrable
          }
        }
      }
    `
  );

  return (
    <SvgStar
      filled={viewerHasStarred}
      label={viewerHasStarred ? "You've starred this." : "Star this!"}
      onClick={() =>
        viewerHasStarred
          ? unstar({ variables: { starrableId: id } })
          : star({ variables: { starrableId: id } })
      }
    />
  );
};

Let's break down what's happening:

  • We're defining mutations for addStar and removeStar. They both simply take an id for the thing we want to star.
  • We make sure this component, <Star />, gets all fresh, updated data it needs back from the mutation by including its fragment on starrable. This will ensure that Relay can update the cache and our component automatically as the mutation is done.

There's really not that much going on here - we do our mutations, make sure the fragment is included so the updated data is returned to us, and Relay will handle all the heavy lifting for us. Relay will even optimize re-rendering after mutating, ensuring that the bare minimum amount of components re-render.

That means this component will be performant by default, no matter where it's used, or how deeply nested it is in your component tree. All without a single React.memo. Pretty sweet!

Read more about Relay and how it's performant by default in this article.

Confused about how this Relay component would be used in your UI? Thinking "Where and when is my data actually fetched for me"? Check out the Relay docs on composing fragments.

Third iteration: Using fields from the specific types

What about if you really do need to use specific fields from the individual types implementing the interface? What about if I need to know the name if it's a Repository? Or the publicUrl if it's a Gist?

Well, fear not, it's possible! And it's easy.

Let's explore this by adding a hover to our star, saying what it is you're starring - a Repository, a Gist or a Topic. We'll also add some metadata specific to the individual types:

  • For a Repository, we'll show "Star this repository" if it's public, and "Star this forked repository" if it's a fork.
  • For Gist, we'll show "Star this private gist" if it's a private gist, and "Star this gist" if it's public.
  • For Topic, we'll show "Star topic '${topicName}'".

Let's code this thing!

// Star.jsx
import { useFragment, useMutation } from "react-relay/hooks";

const getHoverText = (starrable) => {
  switch (starrable.__typename) {
    case "Repository":
      return `Star this ${starrable.isFork ? "forked" : ""} repository`;
    case "Gist":
      return `Star this ${!starrable.isPublic ? "private" : ""} gist`;
    case "Topic":
      return `Star topic '${starrable.name}'`;
    default:
      // Account for any future type implementing the interface
      return "Star this";
  }
};

export const Star = ({ starrable }) => {
  const data = useFragment(
    graphql`
      fragment Star_starrable on Starrable {
        id
        viewerHasStarred
        __typename # We need this to figure out what underlying type this actually is
        
        # We add inline fragments for each possible type we're interested in selecting
        # specific fields for. That way, those fields will be available for us if that's the 
        # type returned in runtime.
        ... on Repository {
          isFork
        }
      
        ... on Gist {
          isPublic
        }
      
        ... on Topic {
          name
        }
      }
    `,
    starrable
  );

  const { viewerHasStarred, id } = data;

  const [star] = useMutation(
    graphql`
      mutation Star_AddStarMutation($starrableId: ID!) {
        addStar(input: { clientMutationId: "", starrableId: $starrableId }) {
          starrable {
            ...Star_starrable
          }
        }
      }
    `
  );

  const [unstar] = useMutation(
    graphql`
      mutation Star_RemoveStarMutation($starrableId: ID!) {
        removeStar(input: { clientMutationId: "", starrableId: $starrableId }) {
          starrable {
            ...Star_starrable
          }
        }
      }
    `
  );

  return (
    <SvgStar
      filled={viewerHasStarred}
      label={viewerHasStarred ? "You've starred this." : "Star this!"}
      hover={getHoverText(data)}
      onClick={() =>
        viewerHasStarred
          ? unstar({ variables: { starrableId: id } })
          : star({ variables: { starrableId: id } })
      }
    />
  );
};

There's a few new things worth noting here:

  • We've added __typename to our fragment. __typename will tell us what the underlying type is at runtime, so we can know what text to output for the hover.
  • We've also added selections for the specific fields we're interested in for each possible type.
  • In getHoverText we've got an explicit default case for when __typename matches something we didn't expect. This is an important habit to have. New types could implement the interface at any point that we haven't accounted for when shipping the component, so we need a way of handling that gracefully.

And voila! We've now extended our component to select and use specific fields from the various possible types implementing the interface, adding specific behaviors depending on the underlying type at runtime.

Take aways

Time to wrap this up! Let's look back at what we've achieved here with a fairly small amount of code:

  • We have a component that's fully reusable for all types implementing the interface Starrable.
  • The component is self-contained and can be iterated on in isolation.
  • Thanks to Relay, the component is performant by default, regardless of where you use it.
  • Any new GraphQL type you add that's starrable, or any existing type you make starrable, now has a component ready to be used with it with no additional work needed. True reusability.

My personal reflection on interfaces

I really dig interfaces, and I believe they're an essential tool for designing a good schema that'll allow you to be smart about how you build your components, and the amount of work you need to do.

But, with that said, I also think interfaces should be treated with respect. Here's a few things I think has helped me a lot when designing schemas using interfaces.

Don't go overboard

Make sure you have a clear idea and motivation for adding an interface. Don't go out and add a load of interfaces just because fields happen to overlap between types - think about what use case the interface enables for the consumers of the API.

Do use them for generalizable features

Leveraging interfaces to define common features for types in GraphQL is great. Indicating that something can be starred, that something can be commented on or that something can be reacted to are a few good examples. Let features/behavior drive what's an interface and what's not.

Combine them with mutations

Where you'd have addBlogPostComment and addUserActivityComment, instead have a single unified addComment leveraging an interface that's indicating that something can be commented on. That'll allow you to build a single <Comments /> component handling everything around comments.

Adding comments to an existing or new type? Make it implement your comments interface, and you can just drop your <Comments /> component on there with no changes needed. True reusability that'll allow you to move fast with confidence.

Further reading

This article is an attempt at getting you thinking about how you can leverage interfaces to design a schema that'll allow you to write smarter and more sustainable components. There's lots more to read on the interfaces-in-GraphQL subject. Here's a few of my favorite articles and resources:

Thank you for reading! And a big thanks to the follow people who have provided great feedback for this article:

@zth zth added the Preview label May 23, 2020
@zth zth changed the title Leveraging GraphQL interfaces to build reusable components Leveraging GraphQL interfaces to build reusable components with Relay May 23, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant