Writing a To-Do App with GraphQL and Web Components
To-Do apps are de rigueur when it comes to demonstrating web app tech stacks. Writing one lets you see how the stack handles CRUD operations, giving you a feel for how it would handle larger, more complex apps. In this post, we'll learn how to write a to-do app using Apollo Elements, the GraphQL web component library.
View the completed project on GitHub
The Plan
The typical to-do app has a backend which contains the authoritative database of to-dos, and a frontend which displays the list and exposes UI for operations like adding, editing, or deleting to-dos. We'll build our frontend out of a single GraphQL query and some GraphQL mutations, and our backend using Apollo server and Koa, with an in-memory "database" consisting of a javascript object.
We'll use Shoelace for our UI components, and Lit as our web component base, so if you want to catch up or take a refresher, check out my blog post.
Non-Goals
For the purposes of this blog post, we're focusing solely on the frontend side, so a proper backend server and database is out of scope. Instead, we'll implement a backend server that plugs in to web dev server. We're still going to write GraphQL resolver functions though, so we could copy parts of our mock backend into a NodeJS server and with some small modifications it would still work.
We also won't be doing any fancy footwork like pagination or advanced cache management. We're assuming a short todo list that fits on one screen.
App Boilerplate
Let's use the Apollo Elements generator to scaffold an app template:
mkdir todo-apollo
cd todo-apollo
npm init @apollo-elements -- \
app \
--uri /graphql \
--install \
--overwrite \
--package-defaults
Once that's done, let's install our dependencies.
npm i -S apollo-server-koa urlpattern-polyfill
And let's add shoelace UI's styles and scripts to /index.html
:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.48/dist/themes/base.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.48/dist/shoelace.js"></script>
And some initial styles in /style.css
:
/style.css
html {
font-family: var(--sl-font-sans);
}
main {
display: flex;
place-content: start center;
}
apollo-app {
width: clamp(400px, 90vw, 90vw);
}
With our boilerplate ready, we're ready to start on our mock backend.
The Backend
Let's define our backend first, starting with the GraphQL schema.
The Schema
GraphQL apps resolve around their schema, so let's define that now. We'll need a type to represent each to-do, and a corresponding input type for mutations. We'll also define our operations, or all the actions our app's users can perform. Those actions are:
- Reading the list of to-dos
- Creating a new to-do
- Editing an existing to-do
- Deleting an existing to-do
type Todo {
id: ID
name: String
complete: Boolean
}
input TodoInput {
todoId: ID
name: String
complete: Boolean
}
type Query {
todos: [Todo]
todo(todoId: ID): Todo
}
type Mutation {
createTodo(input: TodoInput!): Todo
updateTodo(input: TodoInput!): Todo
toggleTodo(todoId: ID!): Todo
deleteTodo(input: TodoInput!): [Todo]
}
In a larger app we might have defined different input types to get stronger input validation for each operation. For the sake of this demo though, a single input with no required fields does the trick.
Copy the above snippets in to /schema.graphql
.
The Database
Next, we need a database to store our todos and some initial content. For demo purposes, we'll store the todo list in-memory. We're going to cut a few corners for the sake of brevity, so don't take this as an example of inspired database design.
We do try to hide our shame somewhat though by keeping our four CRUD operations in the server's context. We'll call those functions to perform our DB operations in our GraphQL resolvers. We also will simulate network lag, delaying reponses some random number of milliseconds.
Our purpose here isn't to write the most efficient backend code, so don't take lessons from these mocks.
Create a file /server.js
and copy the following snippet in.
/server.js
/* eslint-env node */
/* eslint-disable no-console */
import { ApolloServer } from 'apollo-server-koa';
import { readFileSync } from 'fs';
let TODOS;
const INITIAL_TODOS = [
{ id: '0', name: 'Get Milk', complete: false },
{ id: '1', name: 'Get Bread', complete: false },
{ id: '2', name: 'Try to Take Over the World', complete: false },
];
function initTodos() {
TODOS = [...INITIAL_TODOS];
}
function byId(id) {
return x => x.id === id;
}
function getNextId() {
const last = TODOS.map(x => x.id).sort().pop();
return (parseInt(last ?? '-1') + 1).toString();
}
async function randomSleep(max = 800) {
await new Promise(r => setTimeout(r, Math.random() * max));
}
initTodos();
const server = new ApolloServer({
typeDefs: readFileSync(new URL('schema.graphql', import.meta.url), 'utf-8'),
context: {
getTodo(id) {
const todo = TODOS.find(byId(id));
if (!todo)
throw new Error(`TODO ${id} not found`);
return todo;
},
async getTodos() {
await randomSleep();
return TODOS;
},
async addTodo({ name, complete }) {
await randomSleep();
const todo = { id: getNextId(), name, complete };
TODOS.push(todo);
return todo;
},
async updateTodo({ id, name, complete }) {
await randomSleep();
const todo = server.context.getTodo(id);
todo.name = name ?? todo.name;
todo.complete = complete ?? todo.complete;
return todo;
},
async deleteTodo(id) {
await randomSleep();
server.context.getTodo(id);
TODOS = TODOS.filter(x => x.id !== id);
return TODOS;
},
},
resolvers: {
},
});
/** Attaches the Apollo server to web dev server */
export function graphqlTodoPlugin() {
return {
name: 'graphql-todo-plugin',
async serverStart({ app, config }) {
await server.start();
server.applyMiddleware({ app });
console.log(`🚀 GraphQL Dev Server ready at http://localhost:${config.port}${server.graphqlPath}`);
},
};
}
The Resolvers
With that accomplished, our next task is to define resolvers for each of the operations in our schema: todos
, createTodo
, updateTodo
, and deleteTodo
.
Ccopy the following snippet into the resolvers
block of server.js
:
Query: {
async todo(_, { todoId }, context) {
await randomSleep();
return todoId ? context.getTodo(todoId) : null;
},
async todos(_, __, context) {
return context.getTodos();
},
},
Mutation: {
async createTodo(_, { input: { name, complete = false } }, context) {
return context.addTodo({ name, complete });
},
async updateTodo(_, { input: { todoId, name, complete } }, context) {
return context.updateTodo({ id: todoId, name, complete });
},
async toggleTodo(_, { todoId }, context) {
const todo = await context.getTodo(todoId)
return context.updateTodo({ ...todo, complete: !todo.complete });
},
async deleteTodo(_, { input: { todoId } }, context) {
await context.deleteTodo(todoId);
return context.getTodos();
},
},
Since we're relying on the context
functions we defined earlier, our resolvers can stay simple.
The Server
Our backend code is almost ready to go, all we have to do is hook it up to our frontend via the dev server. We need to add two custom dev server plugins:
graphqlTodoPlugin
, which loads our graphql server code on the/graphql
route, andresolveCodegenPlugin
to make sure the dev server loads graphql operations from the code-generated typescript files instead of their graphql sources.@apollo-elements/create/helpers.js
exports this one for your convenience.
Replace the contents of web-dev-server.config.js
with the following snippet:
import { esbuildPlugin } from '@web/dev-server-esbuild';
import { fromRollup } from '@web/dev-server-rollup';
import { graphqlTodoPlugin } from './server.js';
import { resolveCodegenPlugin } from '@apollo-elements/create/helpers.js';
import _litcss from 'rollup-plugin-lit-css';
const litcss = fromRollup(_litcss);
export default {
nodeResolve: true,
port: 8004,
appIndex: 'index.html',
rootDir: '.',
mimeTypes: {
'src/components/**/*.css': 'js',
},
plugins: [
esbuildPlugin({ ts: true }),
resolveCodegenPlugin({ ts: true }),
graphqlTodoPlugin(),
litcss({
include: 'src/components/**/*.css',
exclude: 'src/style.css',
}),
],
};
The Apollo Client
Our Apollo Client uses HttpLink
to connect to the backend server.
To make sure the todo-list renders the latest data, define a type policy which replaces the entire cached todo list every time the todos
query updates. Replace the typePolicies
config object with:
typePolicies: {
Query: {
fields: {
location(): Location {
return locationVar();
},
todos: {
/** overwrite previous array when updating todos. */
merge(_, next) {
return next;
},
},
},
},
},
The App Shell
Next we'll use the URLPattern
proposal polyfill to set up some client-side routing. Open up src/router.ts
and paste in the following.
import { makeVar } from '@apollo/client/core';
import { installRouter } from 'pwa-helpers/router';
import { URLPattern } from 'urlpattern-polyfill';
const pattern = new URLPattern({
pathname: '/todos/:todoId',
});
function makeLocation(location = window.location) {
const { assign, reload, replace, toString, valueOf, ...rest } = location;
return {
__typename: 'Location',
...rest,
todoId: pattern.exec(new URL(rest.href))?.pathname?.groups?.todoId ?? null,
};
}
function update(location = window.location) {
locationVar(makeLocation(location));
}
export const locationVar = makeVar(makeLocation());
export async function go(path: string): Promise<void> {
history.pushState(null, 'next', new URL(path, location.origin).toString());
await new Promise(r => requestAnimationFrame(r));
update();
}
installRouter(update);
Now we're ready to start writing our UI components.
Reading Todos
Let's define a query to display our list. Edit /src/components/app/App.query.graphql
so that our app shell gets the list of todos:
query AppQuery {
location @client { pathname }
todos {
id
name
complete
}
}
Our next step is to add the query operation to our app shell component and render it's data.
Next let's define the component's template in src/components/list/list.ts
,
render(): TemplateResult {
const todos = this.query.data?.todos ?? [];
return html`
<sl-card>
<h2 slot="header">To-Do List</h2>
<ol>${todos.map(({ name, id, complete }) => html`
<li>
<sl-icon name="${complete ? 'check-square' : 'square'}"></sl-icon>
<a href="/todos/${id}">${name}</a>
</li>`)}
</ol>
</sl-card>
`;
}
add some styles in src/components/app/app.css
,
src/components/app/app.css
:host {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, auto));
gap: 12px;
}
ol {
width: 100%;
list-style-type: none;
padding: 0;
}
li {
display: flex;
align-items: center;
gap: 4px;
}
sl-card {
width: 100%;
}
sl-card, sl-card::part(base) {
height: 100%;
}
sl-card::part(header) {
display: flex;
justify-content: space-between;
align-items: center;
}
:host {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, auto));
gap: 12px;
}
ol {
width: 100%;
list-style-type: none;
padding: 0;
}
li {
display: flex;
align-items: center;
gap: 4px;
}
sl-card {
width: 100%;
}
sl-card, sl-card::part(base) {
height: 100%;
}
sl-card::part(header) {
display: flex;
justify-content: space-between;
align-items: center;
}
And don't forget to load the module in src/main.ts
.
import './components/list';
We've fulfilled the first of our requirements: we can read the list of todos!
- Display todo-list
- Add new todos
- Edit todos
- Delete todos
Now we'll move on to the next step by adding a mutation operation to our component.
Adding Todos
Let's define our operation in src/components/app/CreateTodo.mutation.graphql
. The createTodo
operation takes a TodoInput
object and returns a Todo
.
mutation CreateTodo($input: TodoInput!) {
createTodo(input: $input) {
id
name
complete
}
}
Add that mutation to our component by importing and instantiating an ApolloMutationController
with an update
function that appends the new todo to the cached list. For the UI, let's add an icon-button to the header of our main card, and have it toggle a dialog with a form for the new item.
@query('sl-input') input: HTMLInputElement;
@query('#new-dialog') newDialog: SLDialog;
query = new ApolloQueryController(this, AppQuery);
mutation = new ApolloMutationController(this, CreateTodo, {
update: (cache, result) => {
const cached = cache.readQuery({ query: AppQuery, returnPartialData: true });
cache.writeQuery({
query: AppQuery,
data: {
...cached,
todos: [
...cached.todos,
result.data.createTodo,
],
},
});
},
});
render(): TemplateResult {
const todos = this.query.data?.todos ?? [];
return html`
<sl-card>
<h2 slot="header">To-Do List</h2>
<sl-tooltip slot="header" content="Add Todo">
<sl-icon-button name="plus-circle"
label="Add Todo"
@click="${() => this.newDialog.show()}"></sl-icon-button>
</sl-tooltip>
<ol>${todos.map(({ name, id, complete }) => html`
<li>
<sl-icon name="${complete ? 'check-square' : 'square'}"></sl-icon>
<a href="/todos/${id}">${name}</a>
</li>`)}
</ol>
</sl-card>
<sl-dialog id="new-dialog" label="New To-Do Item">
<sl-input ?disabled="${this.mutation.loading}"></sl-input>
<sl-button slot="footer" @click="${this.addTodo}">Add Todo</sl-button>
</sl-dialog>
`;
}
private async addTodo() {
try {
await this.mutation.mutate({
variables: { input: { name: this.input.value } },
});
this.input.value = '';
this.input.blur();
this.newDialog.hide();
} catch (e) {
console.error(e);
}
}
Nice! Two requirements down, two to go:
- Display todo-list
- Add new todos
- Edit todos
- Delete todos
Toggling Todos
Our todo-list will have two different ways to update a todo, toggling it's complete
status, and editing it's content. For the toggle operation, let's create and import ToggleTodo.mutation.graphql
:
mutation ToggleTodo($todoId: ID!) {
toggleTodo(todoId: $todoId) {
id
name
complete
}
}
And for the UI, let's import @apollo-elements/components/apollo-mutation
for a quick one-off. This component will sit in place of the complete icon in our app's template. We'll add a data-todo-id
attribute on <apollo-mutation>
to define a single todoId
variable for the element, and we'll set refetch-queries
and await-refetch-queries
attributes to tell Apollo client to update the list of Todos after mutating, and to keep loading
true until that request resolves. We could do without those, but this way the user gets a nicer experience. refetch-queries
is a declarative alternative to a mutation update function. See the finished code for an example using updater
.
The trigger
attribute on the slotted button tells <apollo-mutation>
to execute when the user clicks, the element will also manage the disabled
state of the button for you. Check out the <apollo-mutation>
docs for more info.
<li>
<apollo-mutation data-todo-id="${id}"
.mutation="${ToggleTodo}"
.optimisticResponse=>
<sl-icon-button trigger name="${complete ? 'check-square' : 'square'}"></sl-icon-button>
</apollo-mutation>
<a href="/todos/${id}">${name}</a>
</li>
Editing Todos
So far we've been adding on to the app shell component for each new feature. At this point our app.ts
file is over 100 lines long. Not terrible, but starting to approach the area we'd want to break it up. For our editing feature, let's define a new <todo-edit>
element with its own mutation operation. We'll fire up the good old generator to scaffold the files:
npm init @apollo-elements -- \
component \
--name todo-edit \
--type mutation \
--operation-name UpdateTodo
--subdir '' \
--overwrite \
At the editor prompt, paste in the following:
mutation UpdateTodo($input: TodoInput!) {
updateTodo(input: $input) {
id
name
complete
}
}
Just like before, we'll define the template and styles. The component features an input field for the text of the todo with a checkbox to indicate the todo's status. Hitting the enter key on or blurring the input, or toggling the checkbox initiates the mutation.
@property({ attribute: 'data-id' }) todoId?: string;
@property({ attribute: 'data-name' }) todoName?: string;
@property({ attribute: 'data-complete', type: Boolean }) complete?: boolean;
@query('sl-input') input: HTMLInputElement;
@query('sl-checkbox') checkbox: HTMLInputElement;
render(): TemplateResult {
return html`
<sl-input label="To-Do Item"
value="${this.todoName}"
@keyup="${event => event.key === 'Enter' && this.mutate()}"
?disabled="${this.mutation.loading}"></sl-input>
<sl-checkbox ?checked="${this.complete}"
?disabled="${this.mutation.loading}"
@click="${this.mutate}">Complete</sl-checkbox>
`;
}
mutate(): void {
this.mutation.mutate({
variables: {
input: {
todoId: this.todoId,
name: this.input.value,
complete: this.checkbox.checked,
},
},
});
}
:host {
display: grid;
gap: 12px;
}
We opted to pass our todo item's properties as attributes into the component in this case, but note that we could have just as well passed the todo object as a DOM property in the app shell's template. Both approaches have their pros and cons.
Let's take a moment to write an update function for our mutation, like the one we wrote for ToggleTodo
. In this case, if we wrote our updater function with readQuery
and writeQuery
, we'd have to manage the entire todos
array ourselves, which would be error-prone and poorly performing. let's use writeFragment
instead. Create a new file at src/components/edit/todo.fragment.graphql
and give it the following content:
fragment todo on Todo {
id
name
complete
}
GraphQL fragments are pretty much just what they sound like, bits of a larger document. The on
clause of the document declaration tells GraphQL which type this fragment belongs to. If we wanted, we could use fancy footwork like importing that todo
fragment into our other operation files, but that's beyond the scope of this tutorial.
Import the fragment into edit.ts
and add this update
function to the mutation controller.
mutation = new ApolloMutationController(this, UpdateTodo, {
update: (cache, result) =>
cache.writeFragment({
fragment: todoFragmentDoc,
id: cache.identify({ __typename: 'Todo', id: this.todoId }),
data: result.data.updateTodo,
}),
});
Be sure to define the update
option as an arrow function, so that the this
binding refers to the element, not the options object.
Ok, we've defined our component but we can't use it yet. To view the edit component, we'll put our client-side router to work in the app shell. Open up src/components/app/app.ts
then add an onData
callback to the query controller that sets a view
property and lazy-loads the edit component.
@state() view = '';
query = new ApolloQueryController(this, AppQuery, {
onData: data => {
this.view = data?.location?.todoId ? 'edit' : 'list';
if (this.view === 'edit')
import('../edit');
},
});
onData
gets called whenever the data changes, with the new data as it's argument. Like above, make sure to use an arrow function so we can access the app shell's state on this
.
We'll also add a second <sl-card>
to display the editor, as a sibling to the first card.
<sl-card ?hidden="${this.view !== 'edit'}">
<h2 slot="header">Edit Todo</h2>
<sl-icon-button slot="header" name="x" label="Close" href="/"></sl-icon-button>
<todo-edit data-id="${this.query.data?.todo?.id}"
data-name="${this.query.data?.todo?.name}"
?data-complete="${this.query.data?.todo?.complete}"
?hidden="${this.view !== 'edit'}"></todo-edit>
</sl-card>
The way our client-side routing works is simple but effective. Whenever the route changes, we lazy-load the desired view component, and we hide the edit card in our template unless the route matches.
By this point, you should be able to add an edit todos, which brings us 3/4 of the way there.
- Display todo-list
- Add new todos
- Edit todos
- Delete todos
Deleting Todos
For our last component let's change things up a little bit. Rather than adding a controller or an <apollo-mutation>
element, we'll call the Apollo client's mutate
method directly.
First, create src/components/edit/DeleteTodo.mutation.graphql
with the following contents, and be sure to import it into edit.ts
.
mutation DeleteTodo($input: TodoInput) {
deleteTodo(input: $input) {
id
name
complete
}
}
Add the following to the <todo-edit>
template:
<sl-button type="danger" @click="${this.deleteTodo}">Delete Todo</sl-button>
When the user clicks the "Delete Todo" button, four things need to happen in sequence:
- Update the
loading
state of the form. We can accomplish this either by callingrequestUpdate
imperatively or by defining a state property on the element and having the template check if that or the mutation controller's loading property is set. - Borrow a reference to the Apollo client from the existing mutation controller and call
mutate()
it, passing theDeleteTodo
document and variables. - Update the cache to remove the deleted todo and update the list of todos.
- Navigate back to the todo list, which causes the "Edit Todo" card to hide.
Let's do all four of those in the deleteTodo
method, importing the go
helper from router.ts
.
private async deleteTodo(): Promise<void> {
const { todoId } = this;
// 1: set loading state
this.loading = true;
try {
// 2: call the mutation imperatively
await this.mutation.client.mutate({
mutation: DeleteTodo,
variables: { input: { todoId } },
/**
* 3: The mutation returns the updated list of todos.
* Overwriting the todos array causes the cache GC
* To remove the missing entry.
*/
update(cache, result) {
const query = AppQuery;
const variables = { todoId };
cache.writeQuery({
query,
variables,
data: {
...cache.readQuery({ query, variables }),
todos: result.data.deleteTodo,
},
});
},
});
// 4: navigate back to the list
await go('/');
} finally {
// clean up loading state.
this.loading = false;
}
}
- Display todo-list
- Add new todos
- Edit todos
- Delete todos
git commit && git push
The End Result
<body>
<main>
<apollo-client id="client">
<p-card>
<h2 slot="heading">To-Do List</h2>
<todo-todos></todo-todos>
<todo-add id="add" refetch-queries="Todos"></todo-add>
<mwc-button id="submit" slot="actions">Add Todo</mwc-button>
</p-card>
</apollo-client>
</main>
</body>
The final product gives us:
- Create, Update, and Delete operations via GraphQL mutations
- Read operation via GraphQL query
- Declarative, maintainable code
Code reviewers (or future us) or will be able to get an at-a-glance perspective on what our code does by reading our GraphQL operation documents. Since we used web components for the UI, we'll be able to incrementally update or swap out our front-end framework with ease (or get rid of it altogether in favour of imperative vanilla JS).
Along the way we learned how to:
- Generate components with
npm init @apollo-elements
- Render a query using the element's
data
property - Fire a mutation to change the data in our graph
- Compose mutation components with queries three ways
- By adding an
ApolloMutationController
to the host element - By using the
<apollo-mutation>
component. - By imperatively calling
client.mutate()
- By adding an
- Update the client side state following a mutation two ways:
- with
refetchQueries
- with
updater
- with
I hope you enjoyed reading and look forward to chatting with you about GraphQL and Web Components on our discord, telegram, or slack channels.