Within this article, I want to provide a helpful solution in Angular for managing local state when working with GraphQL.
This involves 2 technologies: NgRx ComponentStore & Apollo Angular.
This article will cover topics that involve a basic understanding of GraphQL , Apollo
Angular , and NgRx . If you are unfamiliar with any of these technolgies, please read up on them
before going futher.
npm install @ngrx/component-store
ng add apollo-angular
copied = true);
$el.setAttribute('data-checked', 'true');
$el.firstElementChild.classList.add('starting:scale-0', 'starting:opacity-0');
setTimeout(() => $el.removeAttribute('data-checked'), 2500);
" @mouseleave.debounce.1000ms="copied && (copied = false)" @keydown.enter.debounce.1000ms="copied && (copied = false)" @keydown.space.debounce.1000ms="copied && (copied = false)" @touchstart.debounce.1000ms="copied && (copied = false)" class="group flex items-center justify-between gap-2 rounded p-2 select-none hover:bg-surface-3 hover:text-brand focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none motion-safe:transition-colors text-foreground-2 absolute top-0 right-0">
After dependencies have been installed, let’s build out our data model. For this example, we will be working with a
collection of villagers.
src/app/villagers/villagers.ts export interface IVillager {
id : number ;
name : string ;
species : string ;
personality : string ;
}
export type Villagers = Array < IVillager >;
copied = true);
$el.setAttribute('data-checked', 'true');
$el.firstElementChild.classList.add('starting:scale-0', 'starting:opacity-0');
setTimeout(() => $el.removeAttribute('data-checked'), 2500);
" @mouseleave.debounce.1000ms="copied && (copied = false)" @keydown.enter.debounce.1000ms="copied && (copied = false)" @keydown.space.debounce.1000ms="copied && (copied = false)" @touchstart.debounce.1000ms="copied && (copied = false)" class="group flex items-center justify-between gap-2 rounded p-2 select-none hover:bg-surface-3 hover:text-brand focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none motion-safe:transition-colors text-foreground-2 absolute top-0 right-0">
Now that we have our data model in place, lets create a simple component and its module which will inherit our future
local state of villagers.
src/app/villagers/villagers.component.ts import { CommonModule } from '@angular/common' ;
import { Component , NgModule , OnInit } from '@angular/core' ;
@ Component ({
selector : 'app-villagers' ,
template : `` ,
})
export class VillagersComponent implements OnInit {
constructor () {}
ngOnInit () : void {}
}
@ NgModule ({
declarations : [VillagersComponent] ,
imports : [CommonModule] ,
exports : [VillagersComponent] ,
})
export class VillagersModule {}
copied = true);
$el.setAttribute('data-checked', 'true');
$el.firstElementChild.classList.add('starting:scale-0', 'starting:opacity-0');
setTimeout(() => $el.removeAttribute('data-checked'), 2500);
" @mouseleave.debounce.1000ms="copied && (copied = false)" @keydown.enter.debounce.1000ms="copied && (copied = false)" @keydown.space.debounce.1000ms="copied && (copied = false)" @touchstart.debounce.1000ms="copied && (copied = false)" class="group flex items-center justify-between gap-2 rounded p-2 select-none hover:bg-surface-3 hover:text-brand focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none motion-safe:transition-colors text-foreground-2 absolute top-0 right-0">
With the groundwork layed out, we can now begin the fun stuff! Lets start by building a GraphQL Query to which we’ll use
to fetch our collection of villagers. This is where Apollo Angular comes in to play. Apollo Angular gives us the ability
to construct a GraphQL Query in the form of an Angular Service. This can be done like so:
Apollo Angular provides other ways of performing a Query.
src/app/villagers/villagers.gql.ts import { Injectable } from '@angular/core' ;
import { gql , Query } from 'apollo-angular' ;
import { Villagers } from './villager' ;
export interface IVillagersResponse {
readonly villagers : Villagers ;
}
@ Injectable ({ providedIn : 'root' })
export class VillagersGQL extends Query < IVillagersResponse > {
document = gql `
query villagers {
villagers {
id
name
species
personality
}
}
` ;
}
copied = true);
$el.setAttribute('data-checked', 'true');
$el.firstElementChild.classList.add('starting:scale-0', 'starting:opacity-0');
setTimeout(() => $el.removeAttribute('data-checked'), 2500);
" @mouseleave.debounce.1000ms="copied && (copied = false)" @keydown.enter.debounce.1000ms="copied && (copied = false)" @keydown.space.debounce.1000ms="copied && (copied = false)" @touchstart.debounce.1000ms="copied && (copied = false)" class="group flex items-center justify-between gap-2 rounded p-2 select-none hover:bg-surface-3 hover:text-brand focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none motion-safe:transition-colors text-foreground-2 absolute top-0 right-0">
Let’s break down what we just did here. We started by creating our expected response data: IVillagersResponse
. Next,
we setup our actual Angular Service which is simply extending Apollo Angular’s Query
class. By passing
IVillagersResponse in to the class as such, we now gain Type-Saftey around the Query from wherever we use it.
As for the logic within the Service itself, It’s just GraphQL . This is one of the core principles Apollo Angular
sticks to. As you can see, the query is syntactically identical to what a tradional Query would look like. Pretty cool,
huh?
Now that we have a way of retrieving our data and a component to display it, lets begin our local state management. For
this we’ll use NgRx ComponentStore. Let’s start by creating our store.
src/app/villagers/villagers.store.ts import { Injectable } from '@angular/core' ;
import { ComponentStore } from '@ngrx/component-store' ;
import { Villagers } from './villager' ;
export interface IVillagersState {
readonly villagers : Villagers ;
}
@ Injectable ()
export class VillagersStore extends ComponentStore [IVillagersState] {
constructor () {
super ({ villagers : [] });
}
}
copied = true);
$el.setAttribute('data-checked', 'true');
$el.firstElementChild.classList.add('starting:scale-0', 'starting:opacity-0');
setTimeout(() => $el.removeAttribute('data-checked'), 2500);
" @mouseleave.debounce.1000ms="copied && (copied = false)" @keydown.enter.debounce.1000ms="copied && (copied = false)" @keydown.space.debounce.1000ms="copied && (copied = false)" @touchstart.debounce.1000ms="copied && (copied = false)" class="group flex items-center justify-between gap-2 rounded p-2 select-none hover:bg-surface-3 hover:text-brand focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none motion-safe:transition-colors text-foreground-2 absolute top-0 right-0">
With our basic Store now in place, let’s add the logic to interact with our Query we just wrote.
src/app/villagers/villagers.store.ts ...
import { ComponentStore , tapResponse } from '@ngrx/component-store' ;
import { EMPTY } from 'rxjs' ;
import { switchMap } from 'rxjs/operators' ;
import { VillagersGQL } from './villagers.gql' ;
export interface IVillagersState {
readonly villagers : Villagers ;
}
@ Injectable ()
export class VillagersStore extends ComponentStore < IVillagersState > {
constructor ( private readonly villagersGQL : VillagersGQL ) {
super ({ villagers : [] });
}
readonly villagers$ = this .select (({ villagers }) => villagers);
readonly fetchAll = this .effect ((event$) => event$ .pipe (
switchMap (() => this . villagersGQL .fetch () .pipe (
tapResponse (
(res) => this .addMany ( res ?. data ?.villagers) ,
(error) => {
console .error ( `Error fetching villagers: ${ error } ` );
return EMPTY ;
} ,
)
)) ,
));
readonly addMany = this .updater ((_ , villagers : Villagers ) => ({
villagers ,
}));
}
copied = true);
$el.setAttribute('data-checked', 'true');
$el.firstElementChild.classList.add('starting:scale-0', 'starting:opacity-0');
setTimeout(() => $el.removeAttribute('data-checked'), 2500);
" @mouseleave.debounce.1000ms="copied && (copied = false)" @keydown.enter.debounce.1000ms="copied && (copied = false)" @keydown.space.debounce.1000ms="copied && (copied = false)" @touchstart.debounce.1000ms="copied && (copied = false)" class="group flex items-center justify-between gap-2 rounded p-2 select-none hover:bg-surface-3 hover:text-brand focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none motion-safe:transition-colors text-foreground-2 absolute top-0 right-0">
Within our effect, we make use of Apollo Angular’s fetch() method via this.query.fetch()
. This will return a single
Observable emmisson and hook in to the effect’s pipeline with ease. On a successful response, we’ll use the
this.addMany(villagers)
updater to immutably update our villagers state. Finally, we have a selector: villagers$
which we’ll use to async bind to our UI. Now let’s jump back to our component and hook this all up.
src/app/villagers/villagers.component.ts ...
import { VillagersStore } from './villagers.store' ;
@ Component ({
selector : 'app-villagers' ,
template : `
<section>
<div *ngFor="let villager of villagers$ | async">
<h3>{{villager.name}}</h3>
<p>Type: {{villager.species}}</p>
<p>Personality: {{villager.personality}}</p>
</div>
</section>
` ,
providers : [VillagersStore] ,
})
export class VillagersComponent implements OnInit {
readonly villagers$ = this . store .villagers$;
constructor ( private readonly store : VillagersStore ) { }
ngOnInit () : void { this . store .fetchAll (); }
}
...
copied = true);
$el.setAttribute('data-checked', 'true');
$el.firstElementChild.classList.add('starting:scale-0', 'starting:opacity-0');
setTimeout(() => $el.removeAttribute('data-checked'), 2500);
" @mouseleave.debounce.1000ms="copied && (copied = false)" @keydown.enter.debounce.1000ms="copied && (copied = false)" @keydown.space.debounce.1000ms="copied && (copied = false)" @touchstart.debounce.1000ms="copied && (copied = false)" class="group flex items-center justify-between gap-2 rounded p-2 select-none hover:bg-surface-3 hover:text-brand focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none motion-safe:transition-colors text-foreground-2 absolute top-0 right-0">
Within our Component’s initialization, we are now calling this.store.fetchAll()
which is our Store’s effect which in
turn calls our VillagersGQL
Query. With that effect, our local state should then be updated and will now contain a
collection of villagers. We can then async bind our Store’s villagers$
to the DOM.
Ok, so we’ve come along way since we’ve first started; however, there’s still one scenario I’d like to cover which is
GraphQL Mutations. We are able to perform a simple read operation on our data but what about writes? To do so, let build
another Angular Service but instead of implementing the Query class, we’ll implement the Mutation class. For this
example, we will create a Mutation for updating a villager’s name:
Apollo Angular provides other ways of performing a Mutation.
src/app/villagers/villager-edit-name.gql.ts import { Injectable } from '@angular/core' ;
import { gql , Mutation } from 'apollo-angular' ;
import { IVillager } from './villager' ;
export interface IVillagerEditNameVariables {
readonly id : number ;
readonly name : string ;
}
export interface IVillagerEditNameResponse {
readonly editVillagerName : IVillager ;
}
@ Injectable ({ providedIn : 'root' })
export class VillagerEditNameGQL extends Mutation < IVillagerEditNameResponse , IVillagerEditNameVariables > {
document = gql `
mutation editVillagerName($id: ID!, $name: String!) {
editVillagerName(id: $id, name: $name) {
id
name
}
}
` ;
}
copied = true);
$el.setAttribute('data-checked', 'true');
$el.firstElementChild.classList.add('starting:scale-0', 'starting:opacity-0');
setTimeout(() => $el.removeAttribute('data-checked'), 2500);
" @mouseleave.debounce.1000ms="copied && (copied = false)" @keydown.enter.debounce.1000ms="copied && (copied = false)" @keydown.space.debounce.1000ms="copied && (copied = false)" @touchstart.debounce.1000ms="copied && (copied = false)" class="group flex items-center justify-between gap-2 rounded p-2 select-none hover:bg-surface-3 hover:text-brand focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none motion-safe:transition-colors text-foreground-2 absolute top-0 right-0">
Just like our Query class, a Mutation can enforce Type-Safety as well. IVillagerEditNameResponse
for the response and
IVillagerEditNameVariables
for the variables. Also like the Query class logic, It’s just GraphQL .
Let’s now bring this Mutation in to our Store:
src/app/villagers/villagers.store.ts ...
import { VillagerEditNameGQL } from './villager-edit-name.gql' ;
...
@ Injectable ()
export class VillagersStore extends ComponentStore <
IVillagersState ,
> {
constructor (
private readonly villagersGQL : VillagersGQL ,
private readonly editNameGQL : VillagersEditNameGQL ,
) {
super ({ villagers : [] });
}
...
readonly updateOne = this .updater ((state , update: Partial <
IVillager
> ) => ({
villagers : state . villagers .map (villager =>
villager .id === update ?.id
? { ... villager , ... update }
: villager ,
) ,
}));
readonly editName = this .effect ((update$ : Observable <
Pick < IVillager , 'id' | 'name'
>>) => {
return update$ .pipe (
switchMap ((update) => this . editNameGQL .mutate (update) .pipe (
tapResponse (
(res) => this .updateOne ( res ?. data ?.editVillagerName) ,
(error) => {
console .error ( `Error changing villager name: ${ error } ` );
return EMPTY ;
} ,
) ,
)) ,
);
});
}
copied = true);
$el.setAttribute('data-checked', 'true');
$el.firstElementChild.classList.add('starting:scale-0', 'starting:opacity-0');
setTimeout(() => $el.removeAttribute('data-checked'), 2500);
" @mouseleave.debounce.1000ms="copied && (copied = false)" @keydown.enter.debounce.1000ms="copied && (copied = false)" @keydown.space.debounce.1000ms="copied && (copied = false)" @touchstart.debounce.1000ms="copied && (copied = false)" class="group flex items-center justify-between gap-2 rounded p-2 select-none hover:bg-surface-3 hover:text-brand focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none motion-safe:transition-colors text-foreground-2 absolute top-0 right-0">
Just like we did for VillagersGQL, we utilize a ComponentStore effect to invoke this.editNameGQL.mutate(update)
which
returns an Observable and can be hooked in to the pipeline. On successful response, we then call our
this.updateOne(res?.data?.updateVillager)
which updates our single villager record.
Finally, lets update our component to call our effect and update the local state:
src/app/villagers/villagers.component.ts ...
@ Component ({
selector : 'app-villagers' ,
template : `
<section>
<div *ngFor="let villager of villagers$ | async">
<h3
#name
contentEditable
(blur)="editName(villager.id, name.innerText?.trim())">
{{villager.name}}
</h3>
<p>Type: {{villager.species}}</p>
<p>Personality: {{villager.personality}}</p>
</div>
</section>
` ,
providers : [VillagersStore] ,
})
export class VillagersComponent implements OnInit {
...
editName (id : number , name : string ) : void {
this . store .editName ({ id , name });
}
}
...
copied = true);
$el.setAttribute('data-checked', 'true');
$el.firstElementChild.classList.add('starting:scale-0', 'starting:opacity-0');
setTimeout(() => $el.removeAttribute('data-checked'), 2500);
" @mouseleave.debounce.1000ms="copied && (copied = false)" @keydown.enter.debounce.1000ms="copied && (copied = false)" @keydown.space.debounce.1000ms="copied && (copied = false)" @touchstart.debounce.1000ms="copied && (copied = false)" class="group flex items-center justify-between gap-2 rounded p-2 select-none hover:bg-surface-3 hover:text-brand focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none motion-safe:transition-colors text-foreground-2 absolute top-0 right-0">
Apollo Angular maintains the integrity of GraphQL syntax which allows us to quickly pinpoint what a particular Service’s
Query or Mutation is doing (ex: VillagersGQL
). NgRx ComponentStore offers an intuitive api with minimal setup required
for local state management. It also caters to the reactive approach which blends nicely with our Queries and Mutations.
local-state-with-ngrx-and-apollo-angular
An Angular project showcasing the utilization of Apollo-Angular within NgRx ComponentStore.
Introduction