GraphQL servers are becoming more and more popular. Backend developers recognize the added value of switching from traditional REST architecture to GraphQL, despite the higher application complexity.
This article will teach Angular developers the basics of implementing CRUD (create, read, update, delete) operations in the Apollo client. The result will be a movie application where you can create, update, or delete movies entities.
Skipping the UI section, we will be covering the following topics:
- Writing queries & mutations using the Apollo client library
- Generating TypeScript types from the server-side schema by graphql-code-generator
- Updating Apollo’s in-memory cache and common mistakes to watch out for.
Dependencies
Feel free to clone the application from the GitHub repository for a fully functional server. It covers the source code for this example and additional features. After cloning the application, launch the server by running docker-compose up
at the root of the project. The backend will be available at http://localhost:3001/graphql
.
If you are interested in how to build the server side of this application, check out the blog post on How to Create a GraphQL Server with NestJS.
Generate a new Angular application, and in the new Angular project, install the following:
ng add apollo-angular
npm install --save-dev @graphql-codegen/cli
@graphql-codegen/typescript
@graphql-codegen/typescript-apollo-angular
@graphql-codegen/typescript-operations
What is Apollo Client?
Apollo Client is a state management library that enables the management of both local and remote data with GraphQL. Apollo includes local state management, error handling, optimistic responses, testing, extending the server-side schema, and caching responses.
Apollo Client handles the HTTP request cycle from start to finish, including tracking, loading, and error states but also caching HTTP requests. Because every query dispatched by Apollo is just a plain string, which is eventually evaluated on the server side, the Apollo client can verify whether a query with a specific set of arguments was already executed or not. If the query has been already executed, then Apollo loads data from its InMemoryCache. If the query hasn’t been executed yet, Apollo sends the whole string to the GraphQL server for evaluation.
Query data from the server | Query data from the cache |
---|---|
Apollo’s query caching mechanism is beneficial because it prevents users from loading the same data multiple times. On the other hand, caching brings a bit of complexity to our code. For example, if you have a cached list of objects and create a new object, you must manually add that newly created object to the rest of the objects in the cache; otherwise, they will not be grouped together.
We’ll describe an illustration of this problem below when we talk about creating a Movie entity in the Server interaction section.
Set Up Connection to GraphQL Server
First, set up a connection to your GraphQL server from the Angular application. Apollo client needs to know which endpoint (http://localhost:3001/graphql
) to target once it starts executing operations against the server, what kind of header information you want to attach to all the requests, and how you want to handle errors. Create a module named graphql.module.ts
in src/app/graphql
, or move the existing one if the ng add apollo-angular
has already created one and add the following configuration.
// src/app/graphql/graphql.module.ts
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ApolloClientOptions, ApolloLink, InMemoryCache } from '@apollo/client/core'
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { ApolloModule, APOLLO_NAMED_OPTIONS, APOLLO_OPTIONS } from 'apollo-angular';
import { HttpLink } from 'apollo-angular/http';
import { environment } from 'src/environments/environment';
const errorLink = onError(({ graphQLErrors, networkError, response }) => {
// React only on graphql errors
if (graphQLErrors && graphQLErrors.length > 0) {
if (
(graphQLErrors[0] as any)?.statusCode >= 400 &&
(graphQLErrors[0] as any)?.statusCode < 500
) {
// handle client side error
console.error(`[Client side error]: ${graphQLErrors[0].message}`);
} else {
// handle server side error
console.error(`[Server side error]: ${graphQLErrors[0].message}`);
}
}
if (networkError) {
// handle network error
console.error(`[Network error]: ${networkError.message}`);
}
});
const basicContext = setContext((_, { headers }) => {
return {
headers: {
...headers,
Accept: 'charset=utf-8',
authorization: `Bearer random token`,
'Content-Type': 'application/json',
},
};
});
export function createDefaultApollo(httpLink: HttpLink): ApolloClientOptions<any> {
const cache = new InMemoryCache({});
// create http
const http = httpLink.create({
uri: 'http://localhost:3001/graphql',
});
return {
connectToDevTools: !environment.production,
assumeImmutableResults: true,
cache,
link: ApolloLink.from([basicContext, errorLink, http]),
defaultOptions: {
watchQuery: {
errorPolicy: 'all',
},
},
};
}
@NgModule({
imports: [BrowserModule, HttpClientModule, ApolloModule],
providers: [
{
provide: APOLLO_OPTIONS,
useFactory: createDefaultApollo,
deps: [HttpLink],
},
],
})
export class GraphQLModule {}
To configure the Apollo client, we use the dependency injection token APOLLO_OPTIONS
, which is generated before any services are instantiated. Passing a factory function to the APOLLO_OPTIONS token describes how to handle the default GraphQL server endpoint. In createDefaultApollo, we return an object with five properties:
-
connectToDevTools
- allows the usage of Apollo client devtools extension in the local development environment to see Apollo’s cache visually, what data has been already loaded -
assumeImmutableResults
- not required. However, a useful way to prevent data mutation in the global store. By setting assumeImmutableResults to true, Apollo Client only allows immutable updates to the global store in Angular, enabling substantial performance optimizations -
errorPolicy
indefaultOptions.watchQuery
- using all is the best way to notify users about server-side errors because if set to all, then all errors are saved in the cache alongside the returned data (read more in Error handling) -
link
- is a place where we set up handling HTTP requests sent to or received from the backend in a very similar manner as Angular interceptors work. In our example, we can also addbasicContext
.basicContext
attaches additional HTTP header information to the dispatched requests anderrorLink
, which is where client or server-side errors are handled. The only exception is http, where we define the endpoint by which we expect to establish the connection -
cache
- the most important configuration of the Apollo client is its caching mechanism. It can be configured how to handle local-only data, how to extend the server-side schema with additional information, how to identify objects, and much more, but for this example, we leave it empty
After creating apollo.module.ts file, add this module to the imports section of app.module.ts.
NOTE: In real life, it may happen that you want to configure the Apollo client to connect to multiple GraphQL API servers. To accomplish that, use another injection token named
APOLLO_NAMED_OPTIONS
, where you define a key-value pair to endpoints you want to interact with, so it may look as follows.
// src/app/graphql/graphql.module.ts
export function createNamedApollo(httpLink: HttpLink)
: Record<string, ApolloClientOptions<any>> {
return {
spaceX: {
name: 'spaceX',
link: httpLink.create({ uri: 'https://api.spacex.land/graphql/' }),
cache: new InMemoryCache(),
},
};
}
@NgModule({
imports: [BrowserModule, HttpClientModule, ApolloModule],
providers: [
{
provide: APOLLO_OPTIONS,
useFactory: createDefaultApollo,
deps: [HttpLink],
},
{
provide: APOLLO_NAMED_OPTIONS,
useFactory: createNamedApollo,
deps: [HttpLink],
},
],
})
Generating Schema from the Backend
One of the significant benefits of a GraphQL server is that it exposes its whole schema. You can choose any available operation, define the shape of data you expect to be returned and use a library such as graphql-code-generator that allows generating TypeScript types from the server schema.
Using the GraphQL code generator, you avoid manual handwriting of Interfaces, so when the server changes its schema, you can just run one line of code, and the code generator will regenerate all TypeScript types.
To generate types, list every query and mutation you intend to use in files like {{name}}.graphql
. This example demonstrates basic CRUD operations (create, read, update, delete) for movies. Create a file in graphql/graphql-custom-backend/movie.graphql
and paste the following code.
# src/app/graphql/graphql-custom-backend/movie.graphql
fragment MovieInfo on Movie {
id
createdAt
updatedAt
title
description
}
query GetAllMovies {
getAllMovies {
...MovieInfo
}
}
mutation CreateMovie($movieInputCreate: MovieInputCreate!) {
createMovie(movieInputCreate: $movieInputCreate) {
...MovieInfo
}
}
mutation EditMovie($movieInputEdit: MovieInputEdit!) {
editMovie(movieInputEdit: $movieInputEdit) {
...MovieInfo
}
}
mutation DeleteMovie($movieId: Int!) {
deleteMovie(movieId: $movieId)
}
To generate TypeScript types, create a file named codegen.yml
at the root of the application. codegen.yml is a configuration file for graphql-code-generator, which describes the endpoints for inspecting the server-side schema, the places where you want to generate TypeScript types, the plugins you intend to use, and much more. In our case, the codegen.yml looks like the following.
overwrite: true
generates:
# where to generate file
./src/app/graphql/graphql-custom-backend.service.ts:
schema:
# where the server lives
- http://localhost:3001/graphql
documents:
# where are our queries / mutations, etc.
- src/app/graphql/graphql-custom-backend/*.graphql
# necessary to properly generate types
plugins:
- typescript
- typescript-operations
- typescript-apollo-angular
config:
# used to disable lint rule: ts(4114) (override)
addExplicitOverride: true
# Defined other endpoints with similar logic as above
To generate the schema, register an NPM command
“generate-types”: “graphql-codegen --config codegen.yml”
into scripts in package.json. After registering the command and executing npm run generate-types
, you will have a newly created, or in the future, updated graphql-custom-backend.service.ts
file.
Server Interaction
This is where the fun begins. You are going to use the generated GraphQL API calls you defined in movie.graphql
. Create an Angular service in core/api/movie-api.service.ts
and inject the generated types and API calls by Apollo-codegen.
// src/app/core/api/movie-api.service.ts
import { Injectable } from "@angular/core";
import { Apollo } from "apollo-angular";
import {
CreateMovieGQL,
DeleteMovieGQL,
EditMovieGQL,
GetAllMoviesGQL,
} from 'src/app/graphql/graphql-custom-backend.service';
@Injectable({
providedIn: 'root',
})
export class MovieApiService {
constructor(
private getAllMoviesGQL: GetAllMoviesGQL,
private createMovieGQL: CreateMovieGQL,
private editMovieGQL: EditMovieGQL,
private deleteMovieGQL: DeleteMovieGQL,
private apollo: Apollo
) {}
....
}
Read
Before any operation, you must first fetch movies with the following code.
getAllMovies(): Observable<MovieInfoFragment[]> {
return this.getAllMoviesGQL.watch().valueChanges.pipe(
map((res) => res.data.getAllMovies ?? [])
);
}
When executing any query, you can attach watch()
or fetch()
method on these API calls (also pass an object as an argument if you need to send some input data to the server).
he most significant difference between watch() and fetch() is that watch() is reactive. If you replace the old list of movies, getAllMovies, in the Apollo cache with the new list, then by attaching watch(), the content replacement will notify all subscribers. However, with fetch(), subscribers will not get the updated value.
Also, be careful from where you import Observable interface. You may accidentally import it from @apollo/client
, but you end up with an error in this case. Always import it from rxjs
.
When working with multiple GraphQL endpoints, you can use any other endpoint defined for the APOLLO_NAMED_OPTIONS
injection token by the following approach.
@Injectable({
providedIn: 'root',
})
export class SpaceXApiService {
constructor(private apollo: Apollo) {}
getLaunchesPast(): Observable<ApolloQueryResult<unknown>> {
return this.apollo.use('spaceX').query({
query: LaunchesPastDocument,
});
}
}
Where LaunchesPastDocument
is a strongly typed generated query by Apollo-codegen that you could have defined, for example, in spaceX.graphql
to fetch some data from spaceX endpoint. For the spaceX example, visit the provided GitHub repository.
Create
Creating a new movie is a bit more complicated. You do not just have to send the correct data to your backend server, but you also have to save the newly created movie into Apollo’s cache, to group the newly created movie with the already loaded ones.
Send the new movie’s title
and description
to the server, wait for the server’s response and save the returned Movie
object into the getAllMovies
array. We can better describe everything in the following code
createMovie({ title, description }: MovieInputCreate):
Observable<FetchResult<CreateMovieMutation>> {
return this.createMovieGQL.mutate(
{
movieInputCreate: {
title,
description,
},
},
{
optimisticResponse: {
createMovie: {
__typename: 'Movie',
id: -1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
title: title,
description: description,
},
},
update: (store: DataProxy, { data }) => {
const createdMovie = data?.createMovie as MovieInfoFragment;
// query movies from cache
const moviesQuery = store.readQuery<GetAllMoviesQuery>({
query: GetAllMoviesDocument,
});
// movies haven't been loaded yet - no data in cache
if (!moviesQuery?.getAllMovies) {
return;
}
// update cache
store.writeQuery<GetAllMoviesQuery>({
query: GetAllMoviesDocument,
data: {
__typename: 'Query',
getAllMovies: [
...moviesQuery.getAllMovies, createdMovie
],
},
});
},
}
);
}
I want to highlight the update
method. The update
method runs after the mutation has been successfully completed. When executing the createMovieGQL
mutation, you receive back the object MovieInfoFragment
, a fragment of the Movie object that has been created.
However, one thing to remember is even if you have successfully created a Movie
, it is not yet grouped with the rest of the already loaded Movie
objects. When you take a look back at the getAllMovies()
method, you can see that it returns an attribute called getAllMovies
, which contains a list of all MovieInfoFragment
objects.
When you create a new Movie, you have to check Apollo’s cache to see if GetAllMoviesDocument
has already been executed to ensure we have loaded all movies from the server. If not, don’t do anything.
However, if GetAllMoviesDocument
was already executed, then replace the content of GetAllMoviesDocument
with all the previously cached movies and the newly created one:
getAllMovies: [...moviesQuery.getAllMovies, createdMovie]
.
Finally, because we attached the getAllMoviesGQL.watch()
method, all subscribers will be notified about the added element.
Apollo Optimistic Response
When executing any mutation, the operation on the server side may take some time to complete. Usually, server-side operations are counted in milliseconds; however, there can be longer operations that take seconds to complete.
When you want to avoid displaying a “loading spinner” on the UI, you can implement the optimisticResponse
option, where we define the shape of data we expect to be returned by the server.
In the above code example, the update method for createMovieGQL.mutate
runs twice, first with the mocked data defined in the optimisticResponse
option and the second time when the server returns the real data: the actual Movie object that has been created.
No need to worry about having the same object in the getAllMovies
array twice—Apollo is smart enough to replace the old mocked data with the real object once the server finishes its operation and returns the result.
Cannot find namespace ‘JSX’
When working with Apollo client in Angular, you might encounter the error Cannot find namespace‘JSX’
. This happens when you make a wrong import. Because Apollo client can be also used with React, you have to exactly specify the path from which you want to import interfaces in Angular. This error is present by the following import:
// wrong import
import { DataProxy, FetchResult } from ‘@apollo/client’;.
// correct import
import { DataProxy, FetchResult } from ‘@apollo/client/core’;
Edit
Fortunately editing a Movie is much easier. Send the Id, title, and description
attributes of the edited Movie object to the server as it is demonstrated in the following code snippet.
editMovie({id, title, description}: MovieInputEdit):
Observable<FetchResult<EditMovieMutation>> {
return this.editMovieGQL.mutate({
movieInputEdit: {
id,
title,
description
},
});
}
The reason why you don’t need to update getAllMovies
array in the editing process is because of Cache Normalization. Cache Normalization is when an object has some identification key, such as ID, so instead of creating multiple copies of the same object, Apollo maintains references to the normalized object by its unique identifiers. This reference maintenance results in keeping the size of the cache as small as possible and preventing duplicate data. You can inspect this behavior by using Apollo Client Devtools.
Keeping only the Movie references in getAllMovies
array solves the issue of why you don’t need to update getAllMovies array when editing a Movie. You update the Movie entity in one place and every object that keeps the reference will get the updated value.
You also don’t need to implement the update
option in editMovieGQL
because of Apollo’s default behavior in how it handles normalized data. When defining the EditMovie
mutation back in Generating schema from the backend, you included that you expect a MovieInfo
fragment to be returned.
mutation EditMovie($movieInputEdit: MovieInputEdit!) {
editMovie(movieInputEdit: $movieInputEdit) {
...MovieInfo
}
}
It means once the editMovieGQL
mutation successfully executes, you indeed receive an updated version of a Movie object back on the frontend. However, because Apollo has already cached an old version of that edited Movie object with the same ID
in its InMemoryCache
, after the successful operation execution, Apollo just replaces the old Movie object with the newly received one in InMemoryCache
.
Delete
Normalizing data greatly helps us to be consistent. An object can be edited in one place and for every other object, a list of objects or observers that keep a reference to the original one will get an updated value.
Data normalization is also beneficial when it comes to removing an object from the cache. Apollo provides methods for Garbage collection and cache eviction such as evict
that completely removes an object from the cache. So deleting a movie can be done in the following case.
deleteMovie(movieId: number): Observable<FetchResult<DeleteMovieMutation>> {
return this.deleteMovieGQL.mutate(
{
movieId,
},
{
update: (store: DataProxy, { data }) => {
this.apollo.client.cache.evict({id: Movie:</span><span class="p">${</span><span class="nx">data</span><span class="p">?.</span><span class="nx">deleteMovie</span><span class="p">}</span><span class="s2">
});
},
}
);
}
Summary
This article served as the first part of working with Apollo client in Angular. Executing each implemented method in MovieApiService, you can see the result in Apollo Client Devtools. You went through how to generate TypeScript types from the server-side schema, how to configure the Apollo module, how to work with mutations, and how to add an object to an already loaded list of objects. Apollo client is a massive library with lots of options, and for this reason, we will come back to it in the future to try out most of its available features. Until then check the GitHub repository of this example.
Top comments (0)