Angular state management using services built with ez-state

Adrian Brand
6 min readOct 14, 2021

--

One of Angular’s most powerful features is services and dependency injection. Services coupled with RxJs behavior subjects is one of the best ways to manage application state. In this article we will explore using the library ez-state to help us create simple Angular services for state management.

What is ez-state? It is a light weight helper library for creating behavior subjects that manage the state of your service. At the heart of each ez-state cache object is a behavior subject that contains an object of the following interface.

export interface EzState<T> {
value: T;
loading?: boolean;
loaded?: boolean;
loadError?: any;
saving?: boolean;
saved?: boolean;
saveError?: any;
updating?: boolean;
updated?: boolean;
updateError?: any;
deleting?: boolean;
deleted?: boolean;
deleteError?: any;
}

The idea behind ez-state is most application logic in a web app revolves around CRUD actions that call a backend REST API. The cache stores the data for our service in the value property and there are flags that represent the state that the cache is currently in.

There are methods, load, save update and delete that manage the current state flags. They all take an observable that emits the next value. For example the load method takes an observable that emits an object of type T. When load is first called, the loading flag is set to true, loaded is set to false. When the observable emits, loading is set to false, the value is set to the emitted value and loaded is set to true. In case of an error both loading and loaded are false, the previous value is unchanged and loadError is set to the error. This pattern is the same for save, update and delete. Save, update and delete also have corresponding methods, saveIgnoreResponse, updateIgnoreResponse and deleteIgnoreResponse which take an observable of type any that only works on the state flags but does not update the value of the cache.

If we need to interact with the server and and show a spinner while the request is pending we could use saveIgnoreResponse. This would update the saving, saved and saveError flags but the response from the server is not used to update the value property. This means we can use an observable of type any as we do not need to map the server response to the next value of the cache.

To create a users service we would add a cache of type array of user to our service. We can then use the helper getters value$ and loading$ to create observables that represent the data and loading states of our service.

export class UsersService {
private usersCache = new EzCache<User[]>();
users$ = this.usersCache.value$; loading$ = this.usersCache.loading$; constructor(private http: MockHttpService) {} load() {
if (!this.usersCache.value) {
this.usersCache.load(this.http.get<User[]>('user'));
}
}
}

The loading method takes a source observable that emits the same type as the cache. It manages the subscription to the source observable and updates the loading, loaded and loadError properties of the cache.

We can now use this service in a component to show our users.

@Component({
selector: 'app-users',
templateUrl: './users.component.html',
styleUrls: ['./users.component.css'],
})
export class UsersComponent {
users$ = this.usersService.users$;
loading$ = this.usersService.loading$; constructor(private usersService: UsersService) {
usersService.load();
}
}

and display the results in our template.

<h1>Users</h1><app-spinner *ngIf="loading$ | async; else loaded">loading</app-spinner><ng-template #loaded>
<table *ngIf="users$ | async as users">
<thead>
<tr *ngIf="users.length">
<th>Name</th>
<th>Email</th>
</tr>
<tr *ngIf="users.length === 0">
<th>No users</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let user of users">
<td>{{ user.firstname }} {{ user.lastname }}</td>
<td>{{ user.email }}</td>
</tr>
</tbody>
</table>
</ng-template>

The loading flag can be used to display a loading message while the data is being retrieved from the server and then display the data grid once the loading is finished.

If we want to add the ability to delete a user we can add a delete method to the service, expose it to the component and call it from a button press.

delete(user: User) {
this.usersCache.delete(
this.http
.delete(`user/${user.id}`)
.pipe(
map((_) =>
this.usersCache.value.filter((u) => u.id !== user.id))
)
);
}

The delete method takes an observable that emits the updated state of the cache. We take the response from the server and map it to an array of users that does not contain the deleted user.

Now we call add the call to out users component.

delete(user: User) {
this.usersService.delete(user);
}

And call it from a button click. In a real world app we would ask for user confirmation before actioning a delete request but for simplicity in this demo we will just delete the user.

<td><button (click)="delete(user)">delete</button></td>

If we wish to add or edit users we can add some extra state observable to represent the saving, saved and error state of the cache.

saving$ = this.usersCache.saving$;saved$ = this.usersCache.saved$;error$ = this.usersCache.error$;

error$ is a getter helper that generates an observable that emits any error loadError or saveError or updateError or deleteError. In this instance we could use the helper getter saveError$ that only emits the saveError as save is the only action we are implementing in this demo.

Then add a save method and a resetState method to reset the state flags on the cache.

save(user: User) {
if (user.id) {
this.usersCache.save(
this.http
.put<User>(`user/${user.id}`, user)
.pipe(
map((_) =>
this.usersCache.value.map(
(u) => (u.id === user.id ? user : u)
)
)
)
);
} else {
this.usersCache.save(
this.http
.post<User>('user', user)
.pipe(
map((id) =>
[...this.usersCache.value, { ...user, id: id }]
)
)
);
}
}
resetState() {
this.usersCache.setState();
}

The save method takes an observable that emits the updated data in the cache. If it is an existing user we do a http put and map the server response to an array of users with the updated user replaced in the array. If it is a new user we post the data then map to a new array with the new user added.

We can add a reference data service to store values like data for dropdowns such as titles.

@Injectable({
providedIn: 'root',
})
export class RefDataService {
private observables: { [key: string]: Observable<SelectItem[]> } = {};
constructor(private http: MockHttpService) {} get titles$(): Observable<SelectItem[]> {
return (
this.observables.titles$ ||
this.create('titles$', this.http.get<SelectItem[]>('titles'))
);
}
private create(
property: string,
source$: Observable<SelectItem[]>
) {
const cache = new EzCache<SelectItem[]>();
cache.load(source$);
return (this.observables[property] = cache.value$);
}
}

We can now create a user component to add or edit a user.

@Component({
selector: 'app-user',
templateUrl: './user.component.html',
styleUrls: ['./user.component.css'],
})
export class UserComponent {
user$ = combineLatest([
this.usersService.users$,
this.route.paramMap.pipe(map((p) => p.get('id'))),
]).pipe(
map(([users, id]) =>
id
? users.find((u) => u.id === parseInt(id))
: {
id: null,
title: null,
firstname: '',
lastname: '',
email: '',
}
)
);
saving$ = this.usersService.saving$; saved$ = this.usersService.saved$; error$ = this.usersService.error$; titles$ = this.refDataService.titles$; constructor(
private usersService: UsersService,
private refDataService: RefDataService,
private route: ActivatedRoute
) {
usersService.resetState();
}
save(user: User) {
this.usersService.save(user);
}
}

We create a user$ observable that uses the route param to retrieve the user id in the route from the service’s array or return a new empty user if the route param is empty.

We can then create a form to edit the user.

<h1>User</h1><ng-container *ngIf="!(saved$ | async); else saved">
<div class="error" *ngIf="error$ | async as error">
{{ error }}
</div>
<form *ngIf="user$ | async | clone as user" (submit)="save(user)">
<label for="title">Title</label><br />
<select id="title" name="title" [(ngModel)]="user.title">
<option [ngValue]="null">Please select</option>
<option *ngFor="let title of titles$ | async"
[value]="title.value">
{{ title.label }}
</option>
</select><br />
<label for="firstName">Firstname</label><br />
<input
type="text"
id="firstname"
name="firstname"
[(ngModel)]="user.firstname" /><br />
<label for="lastName">Lastname</label><br />
<input
type="text"
id="lastname"
name="lastname"
[(ngModel)]="user.lastname" /><br />
<label for="email">Email</label><br />
<input
type="text"
id="email"
name="email"
[(ngModel)]="user.email" /><br />
<app-spinner *ngIf="saving$ | async; else saveButton">
saving
</app-spinner>
<ng-template #saveButton>
<button>Save</button>
</ng-template>
</form>
<a routerLink="/users">Cancel</a>
</ng-container>
<ng-template #saved>
Update successful <a routerLink="/users">Return to users</a>
</ng-template>

We use resetState when we navigate to the edit user screen to make sure that the saved flag is cleared so we see the edit form and not the successful update message.

The template clones the user object before binding to it so we are not mutating the data held in the cache, if we didn’t clone the object any form mutations on the object would persist in the cache even if we hit cancel.

The clone pipe is a simple pipe that returns a clone of the object which can be used as a throw away view variable.

A complete demonstration can be viewed on StackBlitz at https://stackblitz.com/edit/angular-ivy-dwgetw

--

--

No responses yet