Understanding Relay 3D
Data Driven Dependencies, or 3D for short, is one of Relay’s more advanced and least documented features.
You can definitely use Relay to great effect without it, but if you’re operating at a scale where you need every optimisation possible - or you can’t resist prematurely optimising - then it’s a feature worth knowing about. For me, I learned about and used it out of curiosity.
The aim of this post is to help you understand what 3D is and what problem it solves. It isn’t to show you how to implement 3D. Doing so would involve explaining a lot of extra work on the server and client, since it actually isn’t ready for open source, according to Relay. Therefore, we’re going to treat the plumbing required to get it working as a black box.
The problem
Let’s imagine we’re building a social media type app, where the main way users communicate is via posts.
For our post component, we initially start out very simple, just a post with some text.
In this hypothetical world, our app grows more popular and users are asking why they can’t post images. We listen to our users, so we implement image posting. As time goes on, we also implement other types of posts, let’s say markdown posts, videos, and code-snippets.
We’ve accumulated five types of posts, some of which aren’t particularly cheap to include in our bundle, specifically videos, code-snippets and markdown posts. We need to pull in a syntax highlighter for the code-snippets, which could be as large as 700kb, a video player for videos, and a markdown renderer for markdown posts.
So our post component may look something like this (incredibly simplified)
import { Videos } from "@somevideolib/videos";import { SomeImageGallery } from "some-image-gallery";import { graphql, useFragment } from "react-relay";import type { post$key } from "../__generated__/post.graphql";import ShikiHighlighter from "react-shiki";import Markdown from "react-markdown";
export function Post({ fragmentRef }: { fragmentRef: post$key }) { const post = useFragment( graphql` fragment post on Post { id text markdown code images { url caption } videos { url } } `, fragmentRef );
const isVideo = !!post.videos.length; const isImage = !!post.images.length;
return ( post.code ? ( <ShikiHighlighter language="typescript" theme="github-dark"> {post.code.trim()} </ShikiHighlighter> ) : post.markdown ? ( <Markdown>{post.markdown}</Markdown> ) : isVideo ? ( <Videos videos={post.videos} /> ) : isImage ? ( <SomeImageGallery images={post.images} /> ) : ( <p>{post.text}</p> ) );}The problem we face is that we’re pulling in all the code for all post types, even when we don’t need it. Let’s say 90% of all posts on the app are either text, images or videos. That means we’re pulling in over 700kb alone for two post types that probably won’t even be in a user’s feed!
Solving this isn’t the easiest either, because we don’t know which type of post we need until we get the data back. Let’s say our response looks like this:
{ "data": { "viewer": { "posts": { "edges": [ { "cursor": "R1BDOk46Mw==", "node": { "id": "UG9zdDoz", "text": "my third post!", "images": [], "code": null, "__typename": "Post" } }, { "cursor": "R1BDOk46Mg==", "node": { "id": "UG9zdDoy", "text": "my second post!", "images": [ { "url": "https://some-cdn.com/my-image", "caption": "my cool image" } ], "code": null, "__typename": "Post" } }, { "cursor": "R1BDOk46MQ==", "node": { "id": "UG9zdDox", "text": null, "images": [], "code": "console.log('hello, world')", "__typename": "Post" } } ] } } }}We have three posts here: a normal text post, an image post, and a code-snippet. We therefore have to pull in the image component, and the syntax highlighter to render these.
It’s entirely dependent on the data we get back. We could have fifty posts that are all text except for one code-snippet, and we’d still have to load the syntax highlighter. The components we need are all driven by our data (hence the name).
Let’s say ideally we’d include the most common components in our main bundle (images, video) and only load the heavier, lesser-used ones (markdown, code-snippet) on demand.
To achieve this, we of course can’t statically import them anymore; it has to be done dynamically.
Solving the problem
Before we get into the examples, I’ll quickly define what 3D actually allows us to do.
3D allows our server to tell the client which component to load, based on the data we’re sending back.
So if we want to include the most common types of post in our main bundle, and use 3D to load the others on demand if need be, then a response which includes a code-snippet and a markdown post would look like this:
{ "data": { "viewer": { "posts": { "edges": [ { "cursor": "R1BDOk46Mg==", "node": { "id": "UG9zdDoy", "text": null, "images": [], "post_content_renderer": { "__typename": "PostCodeSnippetRenderer", "code": "console.log('hello, world!')", "__module_operation_post": "postCodeSnippet_renderer$normalization.graphql", "__module_component_post": "post-code-snippet" } } }, { "cursor": "R1BDOk46MQ==", "node": { "id": "UG9zdDox", "text": null, "images": [], "post_content_renderer": { "__typename": "PostMarkdownRenderer", "markdown": "# Hello, world!", "__module_operation_post": "postMarkdown_renderer$normalization.graphql", "__module_component_post": "post-markdown" } } } ] } } }, "extensions": { "modules": [ "postCodeSnippet_renderer$normalization.graphql", "post-code-snippet", "postMarkdown_renderer$normalization.graphql", "post-markdown" ] }}What are the __module__component__post and __module__operation_post fields ?
They are aliases of a field, added to the query by relay. They alias a field we have to add to all our types which are to be used with 3D. The field they alias is js which they pass a module and id argument e.g
js(module: "post-markdown", id: "post.post_content_renderer")We return back a normal query response, but we also add a modules array to the extensions of the GraphQL response.
The client would then dynamically load the components at post-markdown.tsx and post-code-snippet.tsx.
To enable this behaviour, we need to make significant changes to our GraphQL server. This is quite involved and not the focus of this article. However, I will show the main changes to the schema:
type Post implements Node { id: ID! text:String images:[Image!] videos:[Video!] post_content_renderer(supported: [String!]!): PostContentRenderer # rest of fields}
union PostContentRenderer = PostMarkdownRenderer | PostCodeSnippetRenderer
scalar JSDependency
type PostCodeSnippetRenderer { js(id: String, module: String!): JSDependency code: String!}
type PostMarkdownRenderer { js(id: String, module: String!): JSDependency markdown: String!}Now on the client, we can do:
const post = useFragment( graphql` fragment post on Post { id text post_content_renderer @match { ...postMarkdown_renderer @module(name: "post-markdown") ...postCodeSnippet_renderer @module(name: "post-code-snippet") } } `, fragmentRef );We apply the @match directive to the post_content_renderer field, and spread the different renderer components fragments each with a @module directive. @module specifies the component to download if the @match field resolves to a certain type.
The code-snippet component may look like this:
// heavy libimport ShikiHighlighter from "react-shiki";import { graphql, useFragment } from "react-relay";import type { postCodeSnippet_renderer$key } from "../__generated__/postCodeSnippet_renderer.graphql";
export default function PostCodeSnippet({ fragmentRef,}: { fragmentRef: postCodeSnippet_renderer$key;}) { const data = useFragment( graphql` fragment postCodeSnippet_renderer on PostCodeSnippetRenderer { code } `, fragmentRef );
return ( <ShikiHighlighter language="typescript" theme="github-dark"> {data.code.trim()} </ShikiHighlighter> );}and in the main post component, instead of statically importing these renderer components, and therefore always including them in our main bundle, we hand it off to Relay and instead render a match container. The match container component will render the selected component based on what was matched with @match.
Our post component could look something like this now (again, incredibly simplified)
import { Suspense } from "react";import MatchContainer from "react-relay/lib/relay-hooks/MatchContainer";import {Videos} from "@somevideolib/videos"import {SomeImageGallery} from "some-image-gallery"import { graphql,useFragment } from "react-relay";import type { post$key } from "../__generated__/post.graphql";
function Post({fragmentRef}:{fragmentRef:post$key}){ const post = useFragment( graphql` fragment post on Post { id text images { url caption } videos { url } post_content_renderer @match { ...postMarkdown_renderer @module(name: "post-markdown") ...postCodeSnippet_renderer @module(name: "post-code-snippet") } } `, fragmentRef );
const isVideo = !!post.videos.lengthconst isImage = !!post.images.length
return ( post.post_content_renderer ? ( // MatchContainer will suspend when loading component <Suspense fallback={null}> <MatchContainer match={post.post_content_renderer} /> </Suspense> ) : isVideo ? ( <Videos videos={post.videos} /> ) : isImage ? ( <SomeImageGallery images={post.images} /> ) : ( <p>{post.text}</p> ));}So what we have now is a situation where the most commonly used post types are included in our main bundle by default, and the heavier, lesser-used components are dynamically loaded only when they are needed. This is a big improvement.
The flow now looks like this:
There is more you can do with 3D. The client can also negotiate with the server to fall back to another component. For example, let’s say on mobile we couldn’t use our normal markdown library as there isn’t React Native support yet. On the mobile app we could tell Relay to use a different component, in this example a simpler markdown library, maybe without support for plugins.
const post = useFragment( graphql` fragment mobilePost on Post { text post_content_renderer @match { ...postMarkdownSimple_renderer @module(name: "post-markdown-simple") ...postCodeSnippet_renderer @module(name: "post-code-snippet") } } `, fragmentRef );Relay will pass a supported argument to post_content_renderer, with the values being the names of the types of the fields in the field with @match. So in this example the supported argument would be ["PostMarkdownSimpleRenderer","PostCodeSnippetRenderer"] as these are the only potential render options.
On the server when resolving post_content_renderer, when we see that the post is markdown, we can check if the supported array includes the default markdown component, and if not - like in this case - we can check if it supports the simpler version, and return that as the component to be loaded.
Summary
You should now have an understanding of what 3D is, what problem it solves, and whether it’s worth adopting in your app.
In summary, 3D is great when you are operating at a scale where saving every KB is crucial. It’s a fun optimisation to implement even if you aren’t at that scale, but your time is probably better spent elsewhere.
There is client 3D, which is fully ready, but suffers from waterfalls.
It may be worth revisiting if (when?) Relay ships an open-source-ready version, but until then, it’s still good to know it exists!