If you are working with GraphQL and the Apollo client, you may encounter a situation where the backend of your application is not yet complete, but you still want to make progress on the frontend. In such cases, Apollo client provides a useful tool in the form of local resolvers and cache, which allow you to mock data on the client side. This means that you can work on a feature without waiting for the backend APIs to be completed.
Local resolvers in Apollo are functions that populate data for a single field in the schema. By using local resolvers and cache, you can simulate the behavior of the backend APIs and test your frontend implementation. This can save you time and help you get a better understanding of how your application will look and function once it is fully connected to the backend.
Implementation
We will now provide a guide for implementing local resolvers in Apollo with Nuxt.js version 3 and TypeScript. To begin, it is assumed that you have already installed the apollo/client package, which we will use to create our client for communicating with the backend. Since we are working with Vue.js, we will use vue-apollo-composable to make it easier to work with GraphQL queries and mutations.
To initialize our client for working with GraphQL, we will create a file called “apollo-client.ts” in plugins folder, so it will be registered as a plugin.
import { ApolloClient, ApolloLink, createHttpLink } from '@apollo/client/core'
import {
DefaultApolloClient,
provideApolloClient,
} from '@vue/apollo-composable'
import resolvers from '@/apollo-client/resolvers/resolvers.ts'
import getCacheData from '@/apollo-client/cached-data.ts'
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const httpLink = createHttpLink({
uri: config.public.backendGraphqlEndpoint,
})
// add the authorization to the headers
const authMiddleware = new ApolloLink((operation, forward) => {
const cookie = useCookie('authToken')
operation.setContext(({ headers = {} }) => ({
headers: {
...headers,
authorization: `Bearer ${cookie.value}`,
},
}))
return forward(operation)
})
const apolloClient = new ApolloClient({
cache: getCacheData(),
resolvers,
link: authMiddleware.concat(httpLink),
})
provideApolloClient(apolloClient)
nuxtApp.vueApp.provide(DefaultApolloClient, apolloClient)
})
We have now completed the setup of the Apollo client and can use it for communicating with our backend APIs.
As we can see, we have imported some files from our “apollo-client” folder, such as “resolvers” and “cached-data”, and provided them to our client.
Next, we will create our first resolver. This will help us understand the purpose of the “resolvers.ts” and “cached-data.ts” files.
For example, let’s say we want a resolver for projects. Here is an example “project-resolver.ts” file:
import { gql } from '@apollo/client/core'
import { useApolloClient } from '@vue/apollo-composable'
type ProjectType = {
id: string
name: string
}
type ProjectArray = {
projects: ProjectType[]
}
export const projectsLocalQuery = gql`
query getProjects {
projects @client {
projects {
id
name
}
}
}
`
export const createProjectLocalQuery = gql`
mutation createProject($projectInput: ProjectType!) {
createProject(project: $projectInput) @client {
id
name
}
}
`
export default {
Mutations: {
createProject(_: string, { project }: Record<string, ProjectType>) {
const apolloClient = useApolloClient()
const cache = apolloClient.client.cache
let newProjectId = null
cache.updateQuery({ query: projectsLocalQuery }, (data) => {
const oldProjects = data.projects.projects
const prevItem = oldProjects[oldProjects.length - 1]
newProjectId = prevItem ? (parseInt(prevItem.id) + 1).toString() : '1'
return {
projects: {
projects: [
...oldProjects,
{
...project,
id: newProjectId,
},
],
},
}
})
return {
id: newProjectId,
name: project.name,
}
},
},
Query: {
projects: () => {
const apolloClient = useApolloClient()
const cache = apolloClient.client.cache
const data = cache.readQuery({
query: projectsLocalQuery,
}) as Record<string, ProjectArray>
return data.projects.projects
},
},
}
We have now created our first resolver, which contains a query that returns all projects as an array of objects, and a mutation that creates a new project. The resolvers contain the main logic for retrieving and creating projects. The methods “readQuery” and “updateQuery” are provided by our Apollo client, and we can see that we are working with our cache, retrieving data from cache, storing new data in cache, etc.
But how do our queries or mutations know that they need to retrieve data from the cache instead of the actual backend? If we look at the query and the mutation, we can see the keyword “@client”, which tells them to fetch data from our cache and execute the local resolver written on the client side.
Now that we have our first resolver, we can add it to the “resolvers.ts” file, which is imported into the client initialization process. Here is how our “resolvers” file should look like:
import projectResolver from '@/apollo-client/resolvers/project-resolver.ts'
export default {
Query: {
...projectResolver.Query,
},
Mutation: {
...projectResolver.Mutations,
},
}
Every new resolver that we create can simply be added to the “resolvers.ts” file and it will be included in our client resolvers.
The second thing that we are importing into the client initialization process is the “getCacheData” method from the “cached-data.ts” file. This method is used to write the initial cached data to our client cache.
import { InMemoryCache } from '@apollo/client/core'
import { projectsLocalQuery } from '@/apollo-client/resolvers/project-resolver.ts'
const projectsInitialData = [
{ id: '1', name: 'First project' },
{ id: '2', name: 'Second project' },
{ id: '3', name: 'Third third project' },
]
export default function getCacheData() {
const cache = new InMemoryCache()
cache.writeQuery({
query: projectsLocalQuery,
data: {
projects: {
projects: projectsInitialData,
},
},
})
return cache
}
With that, we have wrapped up the entire process of implementing the Apollo cache and resolvers. Now, we can use them in our components.
Here is a simple component that lists all projects, and on click of the ‘add project’ button, it adds a new project using our mutation.
<template>
<button @click="addNewProject">Add new project</button>
<ul>
<li v-for="project in projectsData" :key="project.id">
{{ project.name }}
</li>
</ul>
</template>
<script setup lang="ts">
import { useQuery, useMutation } from '@vue/apollo-composable'
import {
projectsLocalQuery,
createProjectLocalQuery,
} from '@/mocks/apollo-client/resolvers/project-resolver'
/* query - get projects */
const { refetch, result } = useQuery(projectsLocalQuery)
const projectsData = computed(() => result.value?.projects || [])
/* mutation - add new project */
const { mutate, onDone } = useMutation(createProjectLocalQuery)
onDone(() => {
// after create of new project is done
// just refetch the get-projects query to render into list
refetch()
})
const addNewProject = () => {
mutate({
projectInput: { name: `Project ${projectsData.value.length + 1}` },
})
}
</script>
That is how we use queries and mutations in our components, just as we do with backend-connected queries and mutations.
There might be a question about how this approach will help us when the backend is ready. If the structure of new backend queries and mutations are the same as the local ones we have written, all that is needed is to remove the “@client” keyword from the query and the mutation. Additionally, we can remove the local resolvers for these queries/mutations to avoid unused code.
Now that we have covered everything that is needed for implementing and using Apollo Local Resolvers, we can see a couple more examples of queries with filters and a delete mutation.
- Query with filters
export const projectsByFilterLocalQuery = gql`
query getProjectByFilter($filter: ProjectType) {
projects(filter: $filter) @client {
projects {
id
name
}
}
}
`
And resolver
type ProjectFilterVariables = {
filter?: ProjectType
}
Query: {
projectsByFilter: (_: string, { filter }: ProjectFilterVariables) => {
const cache = useApolloClient().client.cache
const data = cache.readQuery({
query: projectsByFilterLocalQuery,
}) as Record<string, ProjectArray>
let newProjects = data.projectsByFilter.projects
if (filter) {
newProjects = newProjects.filter((el: ProjectType) => {
const nameFilter = filter.name
? el.name.toLowerCase().includes(filter.name.toLowerCase())
: true
const idFilter = filter.id ? el.id === filter.id : true
return nameFilter && idFilter
})
}
return newProjects
},
}
- Delete mutation
export const deleteProjectByIdMutation = gql`
mutation deleteProjectById($id: ID!) {
deleteProjectById(id: $id) @client
}
`
And resolver
Mutations: {
deleteProjectById(_: string, { id }: Record<string, string>) {
const cache = useApolloClient().client.cache
cache.updateQuery({ query: projectsByFilterLocalQuery }, (data) => ({
projects: {
projects: data.projects.projects.filter(
(el: ProjectType) => el.id !== id
),
},
}))
},
}
CONCLUSION
As a conclusion, using Apollo Local Resolvers and Cache is a powerful technique for mocking GraphQL queries and mutations on the client side. It allows developers to work on the frontend features without waiting for the backend APIs to be ready, and to have a better understanding of how the final product will look like. With Apollo Local Resolvers and Cache, developers can easily create and manage their own resolvers and cache data, which can be queried just like any other backend-connected queries and mutations. This approach also provides an easy transition to the actual backend APIs, as the structure of local queries and mutations can be reused in the backend-connected ones. In summary, Apollo Local Resolvers and Cache are a valuable tool for improving the development process of GraphQL-based applications, providing flexibility, efficiency, and ease of use for developers.