GraphQL Components that Work in Every Framework
GraphQL Components that Work in Every Framework
You know how it goes. You hear about the hot new framework everyone's posting about. Sooner or later the shine fades on your lame old app's front-end stack. You start dreaming about the next project, how you'll do things differently. Or maybe you commit to The Big Rewrite™️, but it doesn't go as planned. Despairing, despondent, dejected, you wonder if there's any way to get off this churning treadmill.
Friend, let me tell you - there's a solution looking your right in the face. In fact that solution comes built in to the browser you're reading this post with. You can escape the hype cycle for good with web components.
Framework Interoperability
One of the main advantages to web components is that they are just HTML DOM objects. That means they interface with each other and the rest of the app primarily via standards like HTML attributes, DOM properties, and Events. So imagine you had a <spicy-input>
element that fires a spicy-change
event every time the user types a word that's for grown-ups only. If, down the line, you refactor <spicy-input>
by writing it with a different custom-element base class (e.g. swapping LitElement
for FASTElement
) as long as the external API (attributes, properties, named slots, css custom properties or shadow parts, and events) stays the same, you don't have to change any other files.
If reading this gave you a sudden urge to implement that <spicy-input>
element, then you're the hero we're waiting for, so get on it.
With web components, the library or framework you build them with is an implementation detail, and you can swap them out piecemeal without changing the rest of your app.
This also means that you can use web components inside your old-school framework, or with other web-component libraries.
<spicy-input (spicy-change)="onSpicyChange($event)" [value]="spicyValue"></spicy-input>
return <spicy-input onspicy-change={onSpicyChange} value={spicyValue}></spicy-input>;
const spicyInputRef = createRef(null);
const onSpicyChange = e => setSpicyValue(e.target.value);
const [spicyValue, setSpicyValue] = useState('');
useEffect(() => spicyInputRef.current.addEventListener('spicy-change', onSpicyChange));
useEffect(() => (spicyInputRef.current.value = spicyValue), [spicyValue]);
return <spicy-input ref={spicyInputRef}></spicy-input>
<spicy-input on:spicy-change={onSpicyChange} value={spicyValue}></spicy-input>
<spicy-input @spicy-change="onSpicyChange" :value="spicyValue"></spicy-input>
TL;DR: web components work in all frameworks, or none, since they're just HTML and the DOM.
GraphQL Meets Web Components
So interop takes the pressure off "The Big Rewrite™️" and helps us share our work across teams and apps, but web components have another super power which helps us get the job done: encapsulation.
A well-written web component keeps its insides in and its outsides out. By that, I mean that the internal implementation of the component is kept hidden while the external APIs remain stable and accessible.
In the world of GraphQL, that can mean associating a query with a component so that it's data and it's DOM structure are tightly linked. Apollo Elements lets us define query components that render their data to their private shadow DOM.
import { useQuery, html, component } from '@apollo-elements/haunted';
import { gql, TypedDocumentNode } from '@apollo/client/core';
import { PostsQuery } from './Posts.query.graphql';
function Posts({ limit, sort }) {
const { data } = useQuery(PostsQuery, { variables: { limit, sort } });
const posts = data?.posts ?? [];
function onLike() {
const { id } = event.target.closest('li');
this.dispatchEvent(new CustomEvent('like-post', { detail: { id } }));
}
return html`
<h2>Posts</h2>
<ol>
${posts.map(({ id, coverImage, summary, title, url, liked }) => html`
<li id="${id}">
<a href="${url}"><img src="${coverImage}" role="presentation"/> ${title}</a>
<p>${summary}</p>
<button role="switch"
aria-checked="${liked}"
aria-label="toggle liked"
@click="${onLike}">
${liked ? '💔' : '💓'}
</button>
</li>
`)}
</ol>
`;
}
Posts.observedAttributes = ['limit', 'sort'];
customElements.define('posts-list', component(Posts));
In this code snippet, the <posts-list>
component renders a list of post summaries, where each list-item bears the id
of it's post. Ordinarily, we'd be reticent to use element IDs so dynamically, but with Shadow DOM, the internals of <posts-list>
are isolated from the rest of the document, so we as the author have complete control of the shadow root and can do what we like there without worrying about whether it might negatively affect the rest of the page.
And since that internal DOM is so strongly encapsulated, our colleagues on the Angular team can import it into their app with confidence that indeed nothing will go wrong, even if down the line we change the internal structure of the component.
<label>Posts per page
<input type="number" step="10" [(ngModel)]="limit"/>
</label>
<label>Sort
<select [(ngModel)]="sort">
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</label>
<posts-list
[limit]="limit"
[sort]="sort"
(like-post)="onLikePost($event)"
></posts-list>
function PostContainer() {
const [limit, setLimit] = useState(10);
const [sort, setSort] = useState('asc');
return (
<label>Posts per page
<input
type="number"
step="10"
value={limit}
onInput={e => setLimit(e.target.value)}/>
</label>
<label>Sort
<select onInput={e => setSort(e.target.value)}>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</label>
<posts-list
limit={limit}
sort={sort}
onlike-post={onLikePost}
></posts-list>
);
}
function PostContainer() {
const postsListRef = createRef(null);
const [limit, setLimit] = useState(10);
const [sort, setSort] = useState('asc');
useEffect(() => postsListRef.current.addEventListener('like-post', onLikePost));
useEffect(() => postsListRef.current.limit = limit, [limit]);
useEffect(() => postsListRef.current.sort = sort, [sort]);
return (
<label>Posts per page
<input
type="number"
step="10"
value={limit}
onInput={e => setLimit(e.target.value)}/>
</label>
<label>Sort
<select onInput={e => setSort(e.target.value)}>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</label>
<posts-list ref={postsListRef}></posts-list>
);
}
<label>Posts per page
<input type="number" step="10" bind:value={limit}/>
</label>
<label>Sort
<select bind:value={sort}>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</label>
<posts-list limit={limit} sort={sort} on:like-post={onLikePost}></posts-list>
<label>Posts per page
<input type="number" step="10" v-model="limit"/>
</label>
<label>Sort
<select v-model="sort">
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</label>
<posts-list limit="limit" sort="sort" @like-post="onLikePost"></posts-list>
You can write complex single-page apps with just Apollo Elements. Each component manages it's own internal state while using a templating system like lit-html
or an old-school JavaScript framework to hook up properties and events, and the Apollo cache for managing client-side state more generally.
Learn More
Interested to learn more? Read our getting started guide to find out how your team can bring GraphQL to your web app one web component at a time. Or if you've already started, check out the API docs for the low-down on how components work using your favourite web component library.
So dive in, start building GraphQL and web components into your app today!