Fragment Colocation ​
When presenting GraphQL, its features often turn into a box-ticking exercise of comparing it to alternative solutions of server-client API design, until we may ask ourselves whether GraphQL’s strengths mostly lie in bringing a community together with clever decisions we can now all agree and rely on…
However, while some of what makes GraphQL great is that many of its core principles aren’t new ideas, its less talked about strength lies in fragment composition and hierarchical schema design, which matches our data needs for componentized apps.
Introduction to Fragments ​
In GraphQL, fragments have many uses, and the uses of “Fragment Colocation” are basically a combination of many of the other uses for fragments.
Reusing Selection Sets ​
At their most fundamental, fragments allow us to define a selection set and reuse this set in multiple places of our GraphQL document.
import { graphql } from 'gql.tada';
const query = graphql(`
query PostsOverview {
latestPosts {
...PostCard
}
trendingPosts {
...PostCard
}
}
fragment PostCard on Post {
id
text
createdAt
}
`);
In the prior example, we’ve extracted two selection sets into a PostCard
fragment. When a query we’re writing uses the same data in multiple code paths, we may use fragments to only write a re-used selection set once.
Type Conditions ​
Fragments are also used whenever we’re trying to specify that a certain selection set only applies to one possible type of an abstract type, like a union
or interface
.
import { graphql } from 'gql.tada';
const query = graphql(`
query PostsOverview {
latestPosts {
id
...MediaCard
... on MediaPost {
videoUrl
}
...TextCard
... on TextPost {
text
}
}
}
`);
The above example shows a query for a schema where latestPosts
exposes an interface that is implemented by two types; MediaPost
and TextPost
.
We may use fragments to conditionally apply a selection set to either of these types, which is like “Type Narrowing” in GraphQL.
TIP
The above example uses an inline fragment spread, however, the same principle of type conditions applies to regular fragments and fragment spreads.
@include
& @skip
Conditions ​
GraphQL also features two built-in directives, @include
and @skip
, which we can use to conditionally include a fragment, based on a variable we pass to our query.
import { graphql } from 'gql.tada';
const query = graphql(`
query PostsOverview($showDetails: Boolean!) {
latestPosts {
id
text
...PostDetails @include(if: $showDetails)
}
}
fragment PostDetails on Post {
id
author {
name
}
location {
city
}
}
`);
Here, we only include a PostDetails
fragment if $showDetails
is set, which means, fragments also allow us to alter the query based on some input variables. We can use this to slightly alter the result shape based on what components we know we’ll render, while keeping the query itself the same.
Fragment Colocation ​
All the above examples of how we can use fragments may feel vaguely familiar to us, even if this is the first time we’re seeing fragments in action. That might be because fragments are structured very similarly to how components in componentized apps work.
While querying fields is similar to how we access data in front-end code, and hence map the hierarchy of data we need; Fragments are similar to how we may structure components.
import { FragmentOf, graphql } from 'gql.tada';
export const pokemonTypesFragment = graphql(`
fragment PokemonTypes on Pokemon @_unmask {
types
}
`);
export const PokemonTypes = (props: {
data: FragmentOf<typeof pokemonTypesFragment>
}) => {
const { data } = props;
return (
<p>
<h2>Types</h2>
<ul>
{data.types?.map((typing) => <li>{typing}</li>)}
</ul>
</p>
);
};
With fragments, like our pokemonTypesFragment
above, we can define the data a component requires to render right next to the component itself, which keeps concerns on how to fetch this data away from our presentational components, while still defining what data the component requires.
Nested Fragment Composition ​
While colocating fragments is interesting on its own, it really becomes useful once we define more nested components, and compose their fragments.
Let’s create a Pokemon
component that renders the PokemonTypes
component we’ve already defined above:
import { FragmentOf, graphql } from 'gql.tada';
import { pokemonTypesFragment, PokemonTypes } from './PokemonTypes';
export const pokemonFragment = graphql(`
fragment Pokemon on Pokemon @_unmask {
id
name
...PokemonTypes
}
`, [pokemonTypesFragment]);
export const Pokemon = (props: {
data: FragmentOf<typeof pokemonFragment>
}) => {
const { data } = props;
return (
<section>
<p>{data.name}</p>
<PokemonTypes data={data} />
</section>
);
};
As we can see, defining reusing and composing fragments, is just as easy as reusing and composing components.
No matter whether where we’re using the Pokemon
or PokemonTypes
components, as long as we compose fragments upwards, we’ll eventually be able to compose them into a query, at the level of our screen’s code, and hence combine the data requirements of all of our components.
Fragment Masking ​
In the previous examples, you may have noticed the @_unmask
directive.
In gql.tada
, a technique called “Fragment Masking” is applied to the generated types of your fragments, and @_unmask
disables this for the purpose of our example code. Fragment Masking hides the types of a fragment on the fragment’s derived type. This prevents leaking data when composing fragments.
Let’s consider what happens if the Pokemon
component started to accidentally depend on data that only the PokemonTypes
’s fragment defines.
export const Pokemon = (props: {
data: FragmentOf<typeof pokemonFragment>
}) => {
const { data } = props;
return (
<section>
<p>{data.name}</p>
Pokemon now accidentally depends on PokemonTypes’s data: <span>{data.types?.length}</span>
<PokemonTypes data={data} />
</section>
);
};
We can fix this by removing @_unmask
on the PokemonTypes
component’s fragment to re-enable fragment masking. This will effectively “hide” the pokemonTypesFragment
’s data from the Pokemon
component to keep the fragments isolated from one another on a type-level.
import { FragmentOf, graphql, readFragment } from 'gql.tada';
Removing @_unmask isolates this fragment’s data.export const pokemonTypesFragment = graphql(`
fragment PokemonTypes on Pokemon {
types
}
`);
export const PokemonTypes = (props: {
data: FragmentOf<typeof pokemonTypesFragment>
}) => {
We now have to add readFragment() to unwrap the masked fragment: const pokemon = readFragment(pokemonTypesFragment, props.data);
return (
<p>
<h2>Types</h2>
<ul>
{pokemon.types?.map((typing) => <li>{typing}</li>)}
</ul>
</p>
);
};
Inside the inferred TypeScript types, when fragment masking isn’t disabled using @_unmask
, then gql.tada
will infer masked types. In TypeScript, the type that FragmentOf<>
returns may look like the following:
// FragmentType<typeof pokemonTypesFragment> with @_unmask:
type unmaskedPokemonTypes = {
types: ("Bug" | "Dark" | /*...*/ null)[] | null;
};
// FragmentType<typeof pokemonTypesFragment> without @_unmask:
type maskedPokemonTypes = {
[$tada.fragmentRefs]: {
PokemonTypes: 'Pokemon';
};
};
The $tada.fragmentRefs
property above is just a stand-in for the fragment that we've used in our GraphQL document and all selections inside that fragment are not present and hidden.
Why is this the default behaviour?
This is the default behaviour in gql.tada
, and happens unless you add @_unmask
to a fragment. Not only is this a great pattern to prevent mistakes, it also improves TypeScript's inference performance!
We recommend you not to disable Fragment Masking unless you absolutely have to, to enforce fragment composition safety.
Disabling Fragment Masking globally
While fragment masking is the default, you can also switch it off globally, which is equivalent to adding @_unmask
to every fragment.
We don't necessarily recommend starting out with this, since it makes it harder to switch and migrate to fragment masking incrementally, if you decide to do so in the future.
However, if this isn't a concern to you, you can pass a disableMasking
flag to the initGraphQLTada
call:
export const graphql = initGraphQLTada<{
disableMasking: true;
introspection: introspection;
scalars: {
DateTime: string;
};
}>();
Import Diagnostic ​
Fragment colocation and masking helps us manage large amounts of GraphQL documents when creating and composing queries, while keeping data usage isolated and minimal, right next to our UI components. However, a common mistake with this method is that sometimes we may forget to import and use a fragment.
To prevent us from leaving out fragments, the TypeScript plugin has a diagnostic called shouldCheckForColocatedFragments
. This diagnostic will issue a warning if any imports in your documents don't include an exported fragment.
GraphQLSP: Unused co-located fragment definition(s)import { PokemonItem } from './PokemonItem';
import { PokemonItem, PokemonItemFragment } from './PokemonItem';
This ties together the last loose end for the fragment colocation and masking patterns.
Learn more about this diagnostic