GraphQL integration (and API/data fetching in general) becomes quite repetitive and complex as our app scales. New features need to be built that are sort of similar to features that already existed, but what bits they can reuse is not clear (eg: pagination). New members join the team and we’d like them to work on their UI components without worrying about the data fetching logic of the rest of the component tree. Relay takes an opinionated stance to solve some of these problems that are worth understanding and learning from. In this talk, I'm going to motivate the core features in Relay from the ground-up. I'll do hands-on demos to explain the common challenges GraphQL clients run into, how one would fix them without Relay and then fix them with Relay. I'll also touch upon how Relay works and its design briefly and how Relay’s design goal is not just being a high-performance GraphQL client, but also increasing developer productivity and happiness.
How I Went from Being Skeptical about Relay to Falling in Love with It
Transcription
Hello everyone. I'm Tanmay Gopal. I'm very happy to be here. I hope you all are doing fine. I'm going to talk a little bit about falling in love with Relay. So this is my journey of kind of being skeptical about Relay as a GraphQL client and then gradually falling in love with it. My name is Tanmay Gopal and before I get started, I'll give you a quick introduction. So I'm the founder and CEO at Hasura. Hasura is an open source technology startup. We build a GraphQL engine that can connect to primarily your database and other services as well so that you can kind of stitch across them and get a unified GraphQL API. It runs as a Docker container in your own infrastructure. It's open source under the Apache license and you can check it out on GitHub. A lot of this work and this talk has been kind of motivated by the fact that we've been adding Relay support in Hasura and towards the end, I'll show you how you can kind of get started playing around with Relay and Hasura. All right. So let's dive into Relay. Because when you think about GraphQL and you think about integrating GraphQL into your applications, the biggest selling point of GraphQL is that, at least for me, was that it was for the first time an API that I found easy to explore and integrate. So it was like I can kind of look at the way the API is, I can auto-complete it, I can look at the, I can understand how I need to integrate the API because I can look at the types, I can integrate that with my type system, whatever I'm using on the client side and stuff like that. Where Relay fits in is that Relay handles the responsibility or Relay makes it easy for us to achieve the theoretical best data fetch that we can possibly achieve while using GraphQL and staying sane. So I'm going to talk about the staying sane aspect. So how can, while using GraphQL in our app, what is the best possible data fetching that we can do while introducing the least amount of burden on ourselves as developers? So that's kind of where Relay fits in. And this is going to become more clear as we go along. And this is why Relay is amazing and the ideas behind Relay are really amazing. So to kind of motivate or kind of look at one example through this talk, what I'm going to do is take the example of a data dashboard. So this is a front end application. It's a React app. It's a data admin dashboard. So you can see that there's tables on the left. I'm looking at a particular table. That table has columns. That table has a filtering option. There's stuff like that. This app is actually the Hasura console, which is a React app. But in any case, imagine that this is a fairly medium complex React application, just because there's a lot of data fetch happening. So if you look at the red boxes that I've kind of highlighted here, let me just bring in my pointer. If you look at these red boxes that I've highlighted here, those are kind of the different pieces of data that we're fetching from our API. So here we're fetching a list of all of the tables. Here we're fetching information about that particular table. We're only displaying the table name here. Here we're fetching a list of all of the columns so that we can render this dropdown and the column types so that we can render the operations so that you can filter on that column. And here I'm fetching a list of all of the columns so that I can render a table view. So those are kind of the different pieces of data that I need to fetch for this application. Let's look at another view in the same application. So now I'm on a different view. I'm on the modified table view. So here what I'm looking at is I'm looking at the different columns and the types of those columns in my database. So I'm listing out all of the different columns. I'm listing out what the types of those columns are, whether it's a text column or a character column or is it nullable. And then one case, one of the columns that I have clicked on as a user, I have an expanded view. So in this expanded view, I'm not looking at just two properties of this column. I'm looking at like five different properties of this column. So I'm fetching a list of attributes, but for one of those attributes, I'm fetching a larger amount of data. Again, a fairly typical scenario that you can kind of imagine in an application. So let's kind of see how we would have used a GraphQL API to fetch this data and build this app. The first cut, the simplest thing to do is I would have just taken every single UI component and attached a query to it. That's it. So I make a query here to fetch the tables. I make a query here to fetch the table name for a particular ID. Maybe I'm getting this ID from the URL or from a particular component that was clicked on, right? Or from this component that was clicked on. Then I make another component for this filter column kind of UI that I have, where I fetch the columns name and the type. And here for this kind of table browser, I'm making a query to fetch the columns names. So I'm making different queries. It's really easy because all of the data that I need to fetch is at the component level. So the benefit is of having one query per component is that it's super easy. It's modular, quote unquote. This is actually, of course, in fact, it's a terrible idea because you're making a tremendous number of network calls and defeats the whole purpose and things that people say about GraphQL. In fact, it's not just making many network calls. If you look at this UI carefully, you'll see that I'm making a lot of redundant calls. I'm fetching the name here, which I've kind of already fetched here. I'm fetching the column names and types here, which I've already fetched here. So I'm making redundant queries as well, apart from the fact that I'm making multiple queries, which is terrible. So the first optimization that I can do, the simplest optimization that I can do is make one gigantic query. I make one gigantic query for this page, and this is theoretically the best query I can make. So I make a query, I fetch all of the tables in their name for a particular table that I want to render. I fetch the table name and I fetch the kind of columns, the names of the columns that I'm going to show here in the table browser and the types that I'm going to use for showing the different operators. So if it's an integer, I can do equals. If it's a Boolean, I'll show is true, is false, whatever. So this is kind of an optimal query that I can make. This is nice. This is good. But I don't like this idea because even though it's optimal, as a developer who's kind of building these different components, it's inconvenient because the data requirements for a child component, like for example, our filter column UI or our data browser UI, the requirements of that data are kind of somewhere in the ancestor. So it's somewhere in the ancestor component that is kind of making this query. And then I'm going to pass on all of the props to all of the child components gradually. Not a terribly kind of fun experience. So the next thing I can do is introduce fragments. So what I can do is instead of having a gigantic query at the top level, I'll have a query at the top level that refers to different fragments that I have. So whenever I build a child component, let's say I'm building the table header component, I declare a fragment that says, here's a fragment, I want the name. If I'm building the filter UI, I'll say, well, here's a fragment, I want all of the columns, the names of the columns and the types. So I can render this UI. If I'm building this table browser component, I'll basically just fetch the names of the columns. That's all I need to kind of create this table header. So I declare these fragments kind of alongside the components that I build. So let's say I have three files here, in each of these files, I include a fragment. And then in the ancestral component, I'll kind of go to the ancestor query component somewhere. Even though these components have a hierarchy, I have to include this fragment in the ancestor component here or something like that. So that's kind of what the fragment experience would look like. So the pros here are that it's nice because every component that I'm building, I can declare the name of the data, I can declare exactly the data that I want. The cons, though, is that the experience of actually using fragments is not particularly great and it is frequently error prone. I'm going to talk about two particular pain points in just basic vanilla integration of fragments. The first is importing fragments. And the second is that all child components will get access to all of the data. So let's just take a look at what this means. So the first, and this is an example that I've taken from the Apollo client fragments documentation page. If you look at the left here, I have a child component. So imagine this is comments and comments have votes. And so what I'm looking at is I'm looking at the child component where I'm voting on a particular comment. So this has a fragment. And then the parent has the entire query or maybe a parent fragment. The parent has a larger fragment that includes this fragment. So this part is necessary. I need to include my child fragment in the parent fragment. But this is the part that is a little bit irritating. I need to declare, I need to include and import this fragment as a variable inside this fragment. This is irritating. This is not fun. Because now I have to make sure that not only do I include the fragment inside my components, but then I also, whenever I'm writing the parent component, I'm importing that fragment as well and including that fragment here as well, which I may forget to do or all kinds of things can happen. There could be errors. So those are kind of the, it's not an ideal experience. There's another pain point. The other pain point is that if I have fragments that I include in the top level query, what will happen is that all of these components, the React components, where let's say I fetch these components and I assign a variable to them to fetch the table data. When I fetch this data and I assign it a variable, this component will actually end up having access not just to the name attribute that it needs, but also to the columns attribute and also to columns.name and columns.type. And if I look at the columns filter component that needs only name and type, this is fine. I'm going to have access to columns.name and columns.type, but the column browser thing, which only needs access to column name, will also end up getting access to column type. And I have to be careful with the way I pass these props to my child components from the parent query that I have. And it requires a little bit of care. And if I don't take care, what can often happen, especially as we're building applications quickly, let's say I'm building this browser component and I realize I need access to column type, maybe I forget to update the fragment. Because I can do table.columns.type anyway, because I have access to that, because of some other fragment that I was using. I'll be lazy, or I might be lazy, and I might forget to include that attribute in this fragment here. Just because somebody else was requesting for it. And this is going to be the starting point of errors, as soon as we have a large number of fragments. So if you want to make fragments easier, what we really want to do is make sure that fragments get automatically imported and validated as we use them in our components. And we also want the kind of optimizations to happen when we're passing down data, so that we're getting some kind of isolation and modularity in the components that we're writing. So if I feel like I'm writing a component for a fragment, I feel like I only have access to the data that I declared in my fragment. And apart from that, I don't have access to any more data, unless it's explicitly passed down to me from the parent component. And so if you look at Relay, Relay has two design decisions that make this quite easy. The first is that Relay is not just a GraphQL client that is a library that you can include, create GraphQL queries and make GraphQL queries with, but Relay also has a compiler that makes it possible to, that makes it possible for the compiler basically kind of runs like as a build step. So it's running in parallel as you're doing your development workflow. And as you're kind of building fragments and importing fragments in queries, all of the query, all of the fragments getting imported, all of the fragments getting validated is all kind of happening at build time so that I don't get these runtime errors with fragment imports. Or for example, when I think about data masking, when I use the fragment in my component, I can only use the data that I have access to, which is the way that I use the fragment container inside Relay. So to kind of tangibly look at the work that gets removed for me, I do not have to explicitly import fragments in my parent fragment or in my parent query. And when I try to access the data that my particular component will get, I will only have access to those attributes that I've declared in my fragment. So here I get access only to table.name. Here I'll only get access to columns.name and columns.type. And here I'll only get access to columns.name again. So this is nice because if I try to access some other property, I'll get a build time and a compile time error, which is neat. So which is when you use kind of fragments with Relay, this will kind of start working for you automatically. I didn't want to get into more syntax to show you what the setup is like, but just wanted to stress on the concepts that you'll be able to use. The next problem that you'll run into as you start using fragments, fragments are nice, I'm using fragments everywhere, it's great. My components are modular. But the next problem that I'll run into is going to be that I will soon need variables in my fragments. So what I'm going to need is, let's talk about a simple example. Let's say that in this kind of dashboard that I have, I want the users to be able to see only the first three columns. Maybe I have like a thousand column table, and I only want to see the first three columns. So maybe I want to do a limit, which is I want to introduce some kind of a limit or in Relay terminology, you would say you'd call it like, you'd want just the first three. You want only the first three columns. And if that's what you want, you'll notice that you need to have a variable that this particular fragment has. So maybe this particular component has something like a UI that says, how many columns do you want to see? I want to see three, I want to see 10. So I choose the number of columns that I want to see. And then that becomes a user given kind of variable to the fragment. The thing is, this is obviously possible to do, this is valid syntax. But if I want to use a variable in a fragment, I need to declare it at the top level. That means that if I need to use a variable in a fragment somewhere deep in my kind of child in my component hierarchy, I need to declare that variable at the top level query, because I need to declare query variables, which is, as you can imagine, a little bit irritating. I mean, these variables kind of belong somewhere deep down in the component hierarchy. These variables come through user input. These variables kind of don't belong in an ancestor component. Even if you go back to our original query based design, if you had one query per component, I'd be making a stupidly large number of fetches. But I would be able to declare exactly the variables that I need, when I need them, which is cool. And so, if the way this works in Relay is, what I would like to do is kind of be able to declare variables for my fragments locally. I would like to declare them at the fragment level, and I would kind of like to use them at a fragment level as well. And this is where a Relay design concept of having arguments and argument definitions. These are two client side directives, and two client side directives that you can add to any fragment to kind of locally define and declare a variable then and there, at the fragment level, without having to go to the top level query and modify that. And that is really neat. And we'll look at an example soon. But for now, in the next UI example that we look at, but for now, bear in mind that we need some way of being able to handle variables locally. As soon as you realize that you want to handle variables locally, you realize that actually what you wanted to do was, whenever the user is kind of providing input somewhere deep down in the component hierarchy, what you want to do is you want to update that fragment. So if you look at that kind of example where I wanted to view just three columns, maybe the user will kind of provide us more input, and I want to view just 10 columns. And as the user is kind of providing input to us, we want to update just that fragment, and we definitely do not want to run the entire query again. That's kind of what we want to do. We want to use the modularity of fragments, but we want kind of the independent lifecycle ability almost of a query. And let's take a look at a slightly more tangible example. So in applications that you're building, you would frequently see this when this comes for pagination. When you want to kind of paginate through, you have a child component that is rendering a list. And then as you paginate through it, the variables that you're using to fetch that list, those variables are changing. And those variables are changing on demand. Now, when those variables are changing, you want to refetch the list, you don't want to run the entire query again. Or in the use case that I was talking about, which is kind of like an expand use case, or read more kind of use case. And just like I said, this is like wanting the desire for having queries almost for each component. So let's look at a tangible example and what the queries look like. So here I'm now looking at a similar example of where I have a list. And one item of the list, whenever the user clicks on expand, I want to view more information about this attribute. So by default, I only want two pieces, two attributes for list element. And if I expand it, I want five attributes for that list element. And this is for the dashboard that we're building. So if I think about this, what I want to do is, what I want to be able to do is, sorry, what I want to be able to do is, I want to run a query to fetch all of the elements. In this case, the elements are columns. So I want to fetch all of the columns. And I want to fetch, I want to fetch minimal information per column. So let's look at what the minimal information here is. I only want to fetch name and description. So these are the two attributes that I want to show. In case one of the elements is expanded, I want to be able to show expanded information for that particular column. And so now let's look at the expanded query. I want to see name, I want to see like 10 other attributes. And this is a variable that's kind of locally, that's kind of defined at this fragment level. So I'm using argument definitions to define a variable at this fragment level to say, while rendering this list, if this variable happens to be true for this particular column, I want to also include this particular fragment. Now on the first load, our default value is going to be false. So on the first load, we're going to fetch all of the elements with just name and description. When the user clicks on expand, that's when I want to kind of rerun this fragment, but fetch more data for this fragment. That's kind of what I want to do. I want to rerun and re-fetch the data for this particular fragment, only if the expanded is true to fetch more data. And with relay, that is as simple as going to a particular fragment and saying, let's make this fragment re-fetchable, and whenever there's a click or a user event, we'll run a function called re-fetch, which is a function that you have on a fragment with the variable set to true. So this fragment is going to run as a query, it's going to run as a query with this variable set to true so that you can fetch exactly this data. And that is going to happen just for that one column, one instance where I click on expand. And this is really interesting. If you think about the way it works underneath, if you think about the ergonomics, it's like, oh, this fragment is like a query, so I can run this query again. That's a convenient thought process. But in reality, it's not easy to kind of make that work because a fragment can't run in isolation. The fragment needs to run as a part of a larger query. So how can a fragment run by itself? And the secret here is that a relay API would typically implement a node interface and a root resolver for any node. So the relay compatible API, well, it's not compulsory anymore, it's kind of optional, is that every element that you're fetching has an interface or implements an interface called a node, and it has a globally unique ID. And your relay GraphQL API has a root resolver to fetch any node just by its kind of GUID, its globally kind of unique ID. So what relay can do is that when I mark or when I request the client to refetch a particular fragment, the client doesn't have to run the entire query again. The client just reruns the query for that particular column or that particular element that I wanted to rerun. And the client is able to do that because every element is an instance of node and every element has a unique ID. So the query that's kind of running in the background is actually query, element, unique ID and the exact fields that I want. So in our particular example, there is a background query that runs whenever we do a refetch. The query that's running, the underlying GraphQL query that's running is a query on column where ID equal to the current ID for this column that I'm rendering, which is a GUID that I have. And it's fetching the fields name, description, type, nullable, unique, default, whatever. Like it's fetching whatever different attributes that I want. And this is really nice. I get the convenience of using fragments. But at the same time, I get the ability to kind of use, I get the ability to kind of treat each fragment like a query. And this is really convenient. So to kind of summarize, if you start looking at Relay, it can be a little bit overwhelming. If you look at the Relay docs, you look at people talking about Relay, there's a lot going on. Don't worry too much about that. Think about the modularity that you want in your application and the modularity that you can achieve with a good fragment based approach. As you start thinking about that modularity and you start thinking about using fragments in practice, you'll notice that you'll have to rebuild a lot of the same design decisions that Relay enforces on you upfront. For example, unique names of fragments that allow us to import fragments automatically. Having client-side directives that allow us to declare variables for fragments. Having the node interface and a global unique ID that allow us to refetch and rerun fragments on demand. That makes pagination easy. It makes things like expand an element easy. It makes all of those kinds of interactions easier. So today I'm launching, and today we've been working very hard on this over the last few weeks, is you can try Relay out with Hasura. So head to hasura.io slash GraphQL slash Relay. All you need to do is deploy to Heroku if you want to just try things out. Set up your database, do a little bit of data modeling, set up some relationships between these data models that you have, set up the permission rules, and you'll get a Relay API instantly that you can start using and integrating. And then of course, beyond the CRUD kind of APIs that you get, once you want to start adding custom business logic, you can add that in your favorite language as well, Node.js or TypeScript or Python or Ruby or whatever you want, and deploy them in serverless functions or wherever, and that'll kind of get added to your GraphQL API with Hasura as well. And you'll be able to try Relay out. So do try that out. Give us some feedback. We're in beta, and we should be able to release it in stable very, very soon. For this particular talk, I needed a lot of convincing and understanding and handholding, and the Relay docs obviously helped. But two people who especially helped were Gabriel and Sean. You should definitely follow them on Twitter at ZTH and Sgrove. And they also have two really nice blog posts that they've written together on Relay, which talks about the GraphQL client that does the ready work for you and talks about pagination. So you should check that out as well. That's it for me. I am Tamay. You can find me on Twitter. Please feel free to reach out if you have any questions on GraphQL, Hasura, Relay, and I'll do my best at answering them or connecting you to people who can answer them. Thank you. Thanks for listening.