reactgraphql

Use Apollo GraphQL to over-engineer a todo list app

August 23, 2022

GraphQL in a nutshell

From the official documentation:

GraphQL is a query language for your API, and a server-side runtime for executing queries using a type system you define for your data. GraphQL isn't tied to any specific database or storage engine and is instead backed by your existing code and data.

Where REST relies on multiple URL endpoints to retrieve nested, complex data, a single GraphQL request can do the same. In a GraphQL query we request only what we need at the time and are returned exactly that - nothing more. That could mean only a few required fields on a single model (so we don't overfetch) or many resources and their accompanying fields.

This predictable response is made possible by relying on types and fields, not endpoints. We describe the "shape" of our data when defining our schema.

For example, this query on the client:

query User(id: ID!) {
  user(id: "6322352892c3b4c3e190fac4") {
    id
    username
    todos {
      id
      title
      complete
    }
  }
}

Will return this response from the GraphQL server:

{
"data": {
"user": {
"id": "6322352892c3b4c3e190fac4",
"username": "babymax2022",
"todos": [
{
"id": "632249120a2d51daae26de41",
"title": "Change diaper",
"complete": false
},
{
"id": "6322497aa85603a48e36869a",
"title": "Tummy time",
"complete": false
},
{
"id": "6322498ea85603a48e36869d",
"title": "Go poopy",
"complete": true
}
]
}
}
}

As you can see our query very closely resembles the data we get back from the GraphQL service. To see how we arrived at this "shape", let's take a look at a schema, where we defined our types and their fields.

type Todo {
  id: ID!
  title: String!
  complete: Boolean!
  user: User!
}

type User {
  id: ID!
  username: String!
  todos: [Todo!]!
}

type Query {
  getUser(id: ID!): User!
}

Types in GraphQL are regular objects. There are three special types: Query, Mutation and Subscription; all of white are considerd Root types. Every query in GraphQL starts with a Root type. We won't be looking at the Subcription type in this post.

The user and todo types have fields that are composed of scalar types: int, boolean, and string. A bang (!) indicates that it is a non-nullable field. List fields are wrapped in brackets, and associations are defined by a field returning another type. In the example, the todo type has a user field that returns a user object, and the user type has a todos field that returns a list of todo objects.

We can pass arguments to the query type by defining them in our fields. The query type, is still an object, but what makes it special is that it is an entry point to our data; it has a field named getUser with the corresponding resolver:

Query: {
async getUser(parent, args, context) {
const { id } = args;
return await context.User.find({ _id: id }).populate('todos');
},
},

The above resolver communicates with Mongoose to retrieve the user from a MongoDB database. A resolver retrieves data from our store for a certain field.

The mutation type works in the same way as query. It is also an object with fields, arguments, and returns an object type.

type Mutation {
  createTodo(title: String!, complete: Boolean!, user: String!): Todo!
}

The createTodo field could have the following resolver function:

Mutation: {
async createTodo(parent, args, context) {
return await context.Todo.create({
title: args.title,
complete: args.complete,
user: args.user
});
},
},

The mutation string on the client might look like:

mutation CreateTodo {
  createTodo(title: "Change diaper", complete: false, user: "6322352892c3b4c3e190fac4") {
    id
    title
    complete
    user {
      username
    }
  }
}

And the response from the GraphQL server would be:

{
"data": {
"createTodo": {
"id": "632249120a2d51daae26de41",
"title": "Change diaper",
"complete": false,
"user": {
"username:" "babymax2022"
}
}
}
}

GraphQL is database agnostic, you could use it with a MySQL or NoSQL database; it has many front-end libraries and developer tools. For this little demonstration, we are going to tie it all together with Apollo GraphQL, a complete set or client, server, and developer tools for GraphQL.

Apollo Server

For small applications you could create a GraphQL server with Apollo in a single file. What is necessary is that we have defined our types in a schema and have resolvers for each field that is an entry point to our data.

I'm going to go over the few key things we need to get started: our schema, our resolvers, and creating the instance of the Apollo Server.

I have chosen MongoDB as the database for this project. To keep this post focused on Apollo GraphQL, I won't be going over the Mongoose code or how we connect to MongoDB. You are free, however, to check out the source code to see my implementation. You are also free to use another database.

Schema

The schema defines a collection of types and their relationship to each other. Each type is comprised of a set of fields. Schemas are written in schema definition language (or SDL for short). Import the gql function from the Apollo Server library. With gql we can write our schema in SDL, in a template literal.

const { gql } = require('apollo-server');
const typeDefs = gql`
type Todo {
id: ID!
title: String!
complete: Boolean!
}
type Query {
todo(id: ID!): Todo!
todos: [Todo!]!
}
type Mutation {
createTodo(title: String!, complete: Boolean!): Todo!
updateTodo(id: ID!, title: String, complete: Boolean): Todo!
deleteTodo(id: ID!): Todo!
}
`;
module.exports = typeDefs;

Resolvers

Resolvers are responsible for populating data for a single field in your schema; they are what connects GraphQL queries, mutations, and subscriptions to your data store. Resolver functions are passed four arguments in the following order:

  • parent - the return value of the resolver for this field's parent
  • args - an object that contains the arguments we will pass to our field
  • context - used for passing the resolver contextual information or extra functionality, such as auth functions
  • info - information about the operation's execution state, including the field name, and the path to the field from the root

We need to create a resolver for each field that act as an entry point to our GraphQL service.

const resolvers = {
Query: {
async todo(parent, args, context) {
const { id } = args;
return await context.Todo.findOne({ _id: id });
},
async todos(parent, args, context) {
return await context.Todo.find();
},
},
Mutation: {
async createTodo(parent, args, context) {
const { title, complete } = args;
return await context.Todo.create({
title,
complete,
});
},
async updateTodo(parent, args, context) {
const { id, title, complete } = args;
return await context.Todo.findOneAndUpdate(
{ _id: id },
{ $set: { title, complete } },
{ new: true, useFindAndModify: false }
);
},
async deleteTodo(parent, args, context) {
const { id } = args;
return await context.Todo.findByIdAndRemove(id);
},
},
};
module.exports = resolvers;

For this demo application we are using MongoDB for our database. Through the context argument we are able to access methods belonging to the Mongoose API, an Object Data Modelling wrapper for MongoDB.

Instantiating the server

Creating an instance of an Apollo Server is straight forward. Import ApolloServer from it's library into the main entrypoint of our Node app. While calling the constructor it's required to pass in our type definitions and resolvers.

In our case we are using context to pass the Todo model so we have access to it's methods in our resolvers. context is not required and needs to be passed an object. Apollo has a built-in Express server and it is possible to pass the request and response objects to context should your resolver require it.

const { ApolloServer } = require('apollo-server');
const db = require('./config/db');
const Todo = require('./api/todo.model');
const resolvers = require('./api/todo.resolvers');
const typeDefs = require('./api/todo.schema');
const server = new ApolloServer({
typeDefs,
resolvers,
context: { Todo },
});
db();
server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});

Once the server has started you should be able to access the Apollo Studio Explorer, a web IDE for creating, running, and managing GraphQL operations, at http://localhost:4000. The Explorer can be used for testing out our query and mutation fields.

Apollo Client

Queries

useQuery() is Apollo Client's main API for retrieving data in a React project. useQuery() take a query string as it's first argument, and an options argument as it's second argument (more on that later). useQuery() returns an object with three properties that describe the status of our request: data, error, and loading. useQuery() executes on component render.

import { gql, useQuery } from '@apollo/client';
import Form from './components/Form';
import Todo from './components/Todo';
function App() {
const { loading, error, data } = useQuery(
gql`
query GetTodos {
todos {
title
id
complete
}
}
`
);
if (loading) return null;
if (error) return `Error! ${error}`;
return (
<div>
<h1>Todo List GraphQL</h1>
<Form />
{data.todos.map(todo => (
<Todo key={todo.id} todo={todo} />
))}
</div>
);
}
export default App;

To pass our first query to the useQuery() hook we need to import the gql function which allows us to write the GraphQL query language in template literals.

The example above is small, but passing query strings to hooks might get a little unwieldy especially if you have multiple hooks. Besides, what I showed you was for demonstration purposes only. In practice we abstract our query and mutation strings into a separate file.

import { gql } from '@apollo/client';
const GET_TODO = gql`
query GetTodo($id: ID!) {
todo(id: $id) {
id
title
complete
}
}
`;
const GET_TODOS = gql`
query GetTodos {
todos {
title
id
complete
}
}
`;
const CREATE_TODO = gql`
mutation CreateTodo($title: String!, $complete: Boolean!) {
createTodo(title: $title, complete: $complete) {
id
title
complete
}
}
`;
const DELETE_TODO = gql`
mutation DeleteTodo($id: ID!) {
deleteTodo(id: $id) {
id
title
complete
}
}
`;
const UPDATE_TODO = gql`
mutation UpdateTodo($id: ID!, $title: String, $complete: Boolean) {
updateTodo(id: $id, title: $title, complete: $complete) {
id
title
complete
}
}
`;
export { GET_TODO, GET_TODOS, CREATE_TODO, DELETE_TODO, UPDATE_TODO };

You might have noticed our queries and mutations were named twice, in UpperCamelCase and camelCase respectively. The first name is the operation name, is completely optional, and recommended for debugging. The second name is actually the field name on the query or mutation type.

Now we can refactor our code to be less verbose.

import { useQuery } from '@apollo/client';
import { GET_TODOS } from './queries';
// ...
const App = () => {
const { loading, error, data } = useQuery(GET_TODOS);
//...
};

Mutations

We can't rely on the Apollo Studio Explorer to add todos, we're making a stand alone application. We need to be able to add todos through our React front-end.

useMutation() gets passed a mutation string, and returns a mutation function. Unlike useQuery(), the mutation function, named createTodo() to match the field name in our mutation type, must be manually invoked.

createTodo() accepts an options object. We pass a variables property that is also an object containing the arguments we want to pass to our field. This options object can also be passed directly to useMutation().

import { CREATE_TODO, GET_TODOS } from '../queries';
const Form = () => {
// ...
const [createTodo] = useMutation(CREATE_TODO);
const handleSubmit = e => {
e.preventDefault();
createTodo({
variables: { title, complete: false },
});
setTitle('');
};
// ...
};

If we submit our form we will see that our list of todos does not update with our new todo. What gives? This is because we need to refetch our todos query to display the latest data.

import { CREATE_TODO, GET_TODOS } from '../queries';
const Form = () => {
// ...
const [createTodo] = useMutation(CREATE_TODO, {
refetchQueries: [{ query: GET_TODOS }],
});
// ...
};

Any queries where we want to retrieve the updated data, we need to pass to the refetchQueries property in the options object.

import { DELETE_TODO, GET_TODOS } from '../queries';
const Todo = ({ todo }) => {
// ...
const [deleteTodo] = useMutation(DELETE_TODO, {
variables: { id: todo.id },
refetchQueries: [{ query: GET_TODOS }]
},
});
// ...
return (
// ...
{!todo.complete && (
<button onClick={() => deleteTodo()} style={buttonStyle}>
delete
</button>
)}
// ...
);
}

Updating the cache

refetchQueries might be the most straight forward way to get the latest data, but it is not the most optimal. For every query you refetch Apollo makes a network request.

Network requests can be avoided by updating the cache manually. We can use the update() function to do so. update() receives a cache object, that is the Apollo Client cache, and has methods we can use to read and write to the cache such as readQuery() and writeQuery()update() also receives a second object, with a data property, that is the result of the mutation.

The data property has a property containing the field's return type which we described in our schema. In our case data.createTodo returns the new todo object.

type Mutation {
  createTodo(title: String!, complete: Boolean!): Todo!
  ...
}

We can use newTodo to update the cache. First readQuery() executes a query directly on the cache, giving it the query data. Then writeQuery() updates the cache, by setting the data property to include a new array, created from the old cache and newTodo.

const Form = () => {
// ...
const [createTodo] = useMutation(CREATE_TODO);
const handleSubmit = e => {
e.preventDefault();
createTodo({
variables: { title, complete: false },
update: (cache, { data }) => {
const newTodo = data.createTodo;
const todoData = cache.readQuery({
query: GET_TODOS,
});
cache.writeQuery({
query: GET_TODOS,
data: { todos: [...todoData.todos, newTodo] },
});
},
});
setTitle('');
};
// ...
};

Similarly, we don't need to make a network request when deleting a todo (at least in our case). We don't need any additional data from the server. We just need to update the cache to reflect that a todo has been deleted.

const Todo = ({ todo }) => {
// ...
const [deleteTodo] = useMutation(DELETE_TODO, {
variables: { id: todo.id },
update: (cache, { data }) => {
const todo = data.deleteTodo;
const todoData = cache.readQuery({
query: GET_TODOS,
});
const todos = todoData.todos.filter(t => t.id !== todo.id);
cache.writeQuery({
query: GET_TODOS,
data: { todos },
});
},
});
// ...
};

Despite our best approximations there is always the possibility that we may set the cache incorrectly; and what is in the cache, might not be the same as the back end.

While the above code might work, we might encounter a warning in the console:

Cache data may be lost when replacing the todos field of a Query object.

To address this problem (which is not a bug in Apollo Client), define a custom merge function for the Query.todos field, so InMemoryCache can safely merge these objects:

To avoid this warning, we need to set a merge policy in the InMemoryCache options object when we create a new Apollo Client instance.

const client = new ApolloClient({
uri: 'http://localhost:4000',
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
todos: {
merge(existing, incoming) {
return incoming;
},
},
},
},
},
}),
});

When updating a record, Apollo Client is intelligent enough to update the cache, provided the record you are updating is taking the ID as a variable.

const Todo = ({ todo }) => {
// ...
const [updateTodo] = useMutation(UPDATE_TODO);
const handleUpdate = () => {
updateTodo({
variables: {
id: todo.id,
title: titleRef.current.value,
},
});
setEditable(false);
};
// ...
};

Server side, the field must also take the ID as an argument.

type Mutation {
...
updateTodo(id: ID!, title: String, complete: Boolean): Todo!
}

Whether we are creating, updating, or deleting todos we aren't making any network request now when making changes to our list, which is sure to make the senior on your team happy 😎.

Create a client agnostic API with Node, Express and Mongoose

Previous

Being extra by adding Firebase and Firestore to a todo list app

Next

© 2023. Made with ❤️ for Max 👶🏻.