Fragment Colocation and Dynamic Queries with React and Apollo
I remember the first time hearing about data fetching with GraphQL and Relay in a Facebook Engineering talk from the 2015 React.js Conf. I was blown away. The idea of being able to define a component's data requirements, at the component level, which would also roll up and dynamically define a query’s fields, was awesome and exciting.
The Relay framework was released with a lot of rigid specifications and opinions about how data requirements should be organized, which left little room for guess work and provided a clear path for organizing GraphQL. But although it did perform the fragment roll up automatically, that initial implementation (now Relay Classic) wasn't very developer-friendly.
At Ravn, we chose to adopt Apollo’s React implementation, which makes tying UIs to GraphQL a breeze. But Apollo lacks some of the built-in upsides of Relay that initially got me so excited about it, like the data requirement roll up and specifications for fragment naming and colocation. Apollo mentions a couple of these Relay strategies but it lacks the full picture.
Let's see if we can create our own solution to these missing features, using the best of Relay’s strategies inside of Apollo.
The end goal of our solution is to match Relay's most important principle of colocating components with their data dependencies. This is beneficial for a few reasons:
- It becomes obvious at a glance what data is required to render a given component, without having to search which query in our app is fetching the required data.
- As a corollary, the component is decoupled from the query that renders it. We can change the data dependencies for the component without having to update the queries that render them or worrying about breaking other components.
As a best practice, we need to make sure we're only querying what we need. As in Relay, we want queries to be derived from the component making the query and all of its descendents. As an added benefit, we can use TypeScript and graphql-codegen to make the whole thing typesafe.
Our solution will focus on how to accomplish 👆🏻operating under the assumption that we've got a React Apollo project setup with TypeScript and GraphQL Code Generator and a project structure:
TodoScreen
└───TodoList
└───TodoListItem
Let's get down to the neety greety.
Defining Data Requirements
From the GraphQL docs:
Fragments let you construct sets of fields, and then include them in queries where you need to.
Fragments are query building blocks and they're the mechanism each component will use to define its data requirements. A fragment for a TodoListItem
might look like this:
fragment TodoListItem_Todo on Todo {
complete
text
}
As a convention, we name the fragment as <ComponentFileName>_<GraphQLType>
. graphql-codegen
expects all fragments in the app to have unique names, so this convention helps to ensure that. It also makes it impossible to have two of the same fragments spread into another document.
query Todos {
todos {
...TodoListItem_Todo
...TodoList_Todo
vs the possible
...Todo
...Todo
}
}
In an effort to couple the component with its data requirements and make it easy to roll up those requirements (more on this later), we attach the fragments to a fragments
property:
const TodoListItem = () => {...}
TodoListItem.fragments = {
todo: gql`
fragment TodoListItem_Todo on Todo {
complete
text
}
`
}
Attaching the data requirements to the component itself is super nice because you don't have to do any extra work to get both. When you import the component, you get the component and its fragments. Namespacing the fragments on the component has the added benefit of making it very obvious where the fragment is coming from.
A component with fragments will expect a prop matching the keys in the fragments object. If a component defines todo
as a fragment, then it will expect a todo
prop rather than complete
and text
separately and make the parent decide where to get those from.
We can use a TypeScript type to help us enforce the contract between the expected props and the fragment definitions. To do this we create a simple interface that extends React.FC
(Thanks @derrh):
import React from 'react'
import { DocumentNode } from 'graphql'
interface FragC<FragmentProps, Props = {}>
extends React.FC<Props & FragmentProps> {
fragments: Record<keyof FragmentProps, DocumentNode>
}
FragC
(short for Fragment Component and FC was already taken) takes two type arguments:
FragmentProps
Props
FragmentProps
maps to the defined fragments and uses the generated fragment types. Props
are any non API-data-requirement props. Now we have a way to enforce the contract between expected props and fragment definitions.
With FragC
in our arsenal we can update TodoListItem
like so:
interface FragmentProps {
todo: TodoListItem_TodoFragment
}
const TodoListItem: FragC<FragmentProps> = ({ todo }) => {...}
TodoListItem.fragments = {
todo: gql`
fragment TodoListItem_Todo on Todo {
complete
text
}
`
}
TodoListItem_TodoFragment
is generated based on the fragment definition, so it needs to exist before the FragmentProps
type can be completed. We get a sort of chicken and egg situation here though. We need the interface to match the fragments, and the fragments to match the interface. I'll typically type the fragments as any
until the fragment type can be generated.
interface FragmentProps {
todo: any
}
Once the type is generated, I’ll plug it in and typescript will now warn us when either the fragments or prop types don’t align.
Rolling Up Data Requirements
At this point we've pretty thoroughly accomplished colocation of components with their data dependencies. I think we've also made it obvious, at a glance, the data required to render a given component. But we're not done yet. We need to use the data requirements of all the descendents in a view hierarchy to define the final query, which gets sent to the API. There are a couple more things we need to do in order to bring it all together.
First, we have to make sure that if a component renders any children with fragments, that the children are included (rolled up) in the parent component's fragment list.
Let’s start by hooking TodoListItem
into TodoList
:
import TodoListItem from ...
interface FragmentProps {
todo: TodoList_TodoFragment
}
const TodoList: FragC<FragmentProps> = ({ todo }) => {
return (
...
<TodoListItem todo={todo} />
...
)
}
TodoList.fragments = {
todo: gql`
fragment TodoList_Todo on Todo {
# If TodoList had any data requirements they would also be listed here
...TodoListItem_Todo
}
${TodoListItem.fragements.todo}
`
}
Even if TodoList
does not have any of its own data requirements, it should still define fragments so it can pass TodoListItem
s along in order to eventually be used in a query. Speaking of which, here is the hooked up TodoScreen
all ready to make a query:
import TodoList from ...
interface FragmentProps {
todo: TodoScreen_TodoFragment
}
const TodoScreen: FragC<FragmentProps> = ({ todo }) => {
return (
...
<TodoList todo={todo} />
...
)
}
TodoScreen.fragments = {
todo: gql`
fragment TodoScreen_Todo on Todo {
...TodoList_Todo
}
${TodoList.fragements.todo}
`
}
The resulting fragment is a combination of every descendent in the hierarchy's dependency on Todo
. The last thing left to do is actually make the query, which will include this TodoScreen.fragments.todo
fragment:
const TodosList = gql`
query todos {
todos {
...TodoScreen_Todo
}
}
${TodoScreen.fragments.todo}
Aaaand we've done it. Any time a data requirement changes in the children it will be reflected in this query. There will never be a question of whether or not a field from a query can be removed. The data fetched should always be an accurate representation of the needs of the UI, holding true to the spirit of GraphQL and eliminating overfetching.
Conclusion
React has thrived by joining colocating views, styles, and application logic together, so adding data requirements to that list just makes sense. In the same way that components are composable, using the strategy we've defined, we can make GraphQL queries composable and reactive to changes. We can tell at a glance what data is required to render a given component, and we can change those data dependencies without having to update the queries that render them, or worry about breaking other components.