What is Relay?
Relay is a JavaScript framework for fetching and updating GraphQL data in React. This is achieved by declaring the data requirements on a per-component basis while Relay takes care of optimising data fetching and performance. Relay provides a neat API for data management using custom React hooks, such as useQueryLoader for fetching data and useFragment for building imperative requests.
Using Relay
At Tines, we use Relay to fetch and mutate data throughout the product. Our area of focus here will be the Tines Storyboard.
This gigantic React component is the "bread-and-butter" of the application and using Relay loads a substantial amount of data.
The query can be broken down into three main chunks: fetching the "Story", "Story Runs", and "Form".
Using the useFragment
hook in each sub-component, we define the data required before neatly bundling it all together in a parent query using useLazyLoadQuery
. When we then navigate to the Storyboard in the product, Relay automatically fires a request to the backend server and populates the components with data once ALL OF IT arrives (despite only needing one of the three storyboard data chunks at any one time). While the component is suspended, a loading indicator is displayed.
export default function Story({ storyId }: Props) {
// Fetch request is made as soon as the component begins to render
const data = useLazyLoadQuery<storyBoardQuery>(
graphql`
query storyBoardQuery($id: ID!) {
story(id: $id) {
...form_query
...diagram_query
...storyRuns_query
}
}
`,
{ id: storyId }
);
return (
<Suspense fallback={<LoadingIndicator />}>
{/* <StoryBoardContent /> contains:
- <Diagram />
- <StoryRuns />
- <Form />
*/}
<StoryBoardContent data={data} />
</Suspense>
);
}
Despite the big strides Relay makes towards optimising the data fetch, the query takes a substantial amount of time to retrieve the data from the backend, and we are forced to show a loading indicator for a significant amount of time. We are also warned by Relay of "waterfalling round trips" that likely contribute to the duration of the query. Given the importance of this component, a lengthy wait time just to view the story simply will not cut it!
Exploring alternative approaches
Before searching for a new and improved way of fetching big chunks of data, we should start by defining our requirements. This will keep "our eyes on the prize" and steer us away from falling down a rabbit hole of premature optimisations or unnecessary over-complications.
The motivations for improving the data query we use include:
Faster storyboard load and less loading indicator time
Decoupling tightly bound components that rely on a shared parent query to fetch data
And finally, the paramount reason for a new fetching solution, ✨ new loading states ✨ from the design team
Solution: usePreloadedQuery & useQueryLoader
The most obvious and immediate solution to speeding up a large query is to parallelise it like Amdahl's law promises. More queries in parallel should result in data arriving faster, but how do we fetch in parallel in Relay?
In our existing approach, we use fragments (form_query
, diagram_query
& storyRuns_query
) for each independent chunk of the diagram and then assemble them in a parent query. Well, what if we just didn't assemble them before fetching?!
This is where usePreloadedQuery & useQueryLoader hooks come into play. Though their original intended use, according to Relay docs, is to pre-load data before the user ever visits a component, we are unable to do this in our Storyboard. It would be unreasonable to load all stories before a user ever visits them, so instead, we can use the hooks to start loading the data we require after the user navigates to their selected story. If we use three of these hooks, one for each "Story", "Story Runs", and "Form", we will fire three requests to the backend in parallel instead of one big parent query with the useLazyLoadQuery
. As soon as data is fetched for any of the three requests, it is ready to display, and we no longer block the whole component to load until the remaining data arrives!
export default function Story({ storyId }: Props) {
const [diagramQueryRef, loadDiagramQuery] =
useQueryLoader<DiagramQueryType>(DiagramQuery);
const [formQueryRef, loadFormQuery] =
useQueryLoader<FormQueryType>(FormQuery);
const [storyRunsQueryRef, loadStoryRunsQuery] =
useQueryLoader<StoryRunsQueryType>(StoryRunsQuery);
// Once the component begin to render we fire three Relay queries to
// fetch data
useEffect(() => {
loadDiagramQuery({ id: storyId });
loadFormQuery({ id: storyId });
loadStoryRunsQuery({ id: storyId });
}, [
loadDiagramQuery,
loadFormQuery,
loadStoryRunsQuery,
storyId,
]);
return (
<>
{diagramQueryRef && (<Diagram queryRef={diagramQueryRef} />)}
{formQueryRef && (<Form queryRef={formQueryRef} />)}
{toolbarQueryRef && (<StoryRuns queryRef={storyRunsQueryRef} />)}
</>
);
}
We then simply retrieve the data from the Relay store in the component that declared it using usePreloadedQuery.
export default function Form({ queryRef }: Props) {
// Pull retrieved data from the Relay store
const data = usePreloadedQuery(
graphql`
query FormQuery($id: ID!) {
story(id: $id) {
form {
id
name
description
visibility
}
}
}
`,
props.queryRef // The key for fetching data from the Relay store
);
const form = data.story?.form;
return (
<>
<div>{form.id}</div>
<div>{form.name}</div>
<div>{form.description}</div>
<div>{form.visibility}</div>
</>
);
}
This solution also allows us to retain the seamless transition between each of the three storyboard sub-components without observing loading states between them; the data already lives in the relay store from the initial parallelised query fetch.
As we no longer have to rely on a parent query to begin fetching data, one could argue we have achieved some decoupling here, leading to faster and easier refactors in the future. Additionally, when we declare the data requirements for each component, we define the type for each query independently, visually and structurally, separating concerns and promoting readability.
Finally and most importantly, using three separate hooks to load our Storyboard means we can have three individual suspense states, one for each data chunk. Before this, the approach using useLazyLoadQuery left us with just one suspense state shared among the entire Storyboard: a loading indicator.
return (
<>
<Suspense fallback={<DiagramSkeleton />}>
{diagramQueryRef && (<Diagram queryRef={diagramQueryRef} />)}
</Suspense>
<Suspense fallback={<FormSkeleton />}>
{formQueryRef && (<Form queryRef={formQueryRef} />)}
</Suspense>
<Suspense fallback={<StoryRunsSkeleton />}>
{storyRunsQueryRef && (<StoryRuns queryRef={storyRunsQueryRef} />)}
</Suspense>
</>
);
With this new solution, we can jump right into adding a loading state for the "Story", "Story Runs", and "Form", which better maps to the final loaded screen look and earns us some brownie points with the design team!