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 parentargs
- an object that contains the arguments we will pass to our fieldcontext
- used for passing the resolver contextual information or extra functionality, such as auth functionsinfo
- 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 😎.