Forewords: In the following post I give an example of how to use @microphi/store for managing state in angular apps. The project is just a PoC at the time of writing. You can find more here https://github.com/microph1/microphi/tree/master/projects/store.
The code snippets you find below are part of an example app that can be found at https://github.com/microph1/ticket-store-example
I wanted to get my head around state management in angular. So I went for ngrx. Watched some videos, read some tutorials. A big mess in my head and not really sure what I was doing. Boilerplate code? Yeah, sad days!
Then I gave a look at Reduxjs. Much better. Less boilerplate. Much less documentation to go through. But still some boilerplate and also the fact that you need to write "pure" functions in an environment where you thought yourself to use classes and OOP didn't make much sense in my head. Yeah maybe it's easier to test but still.
So I started to write a state manager myself. Happy days!
Let's suppose you've got your brand new angular app with a service to handle tickets.
// ticket.service.ts
import { Injectable } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { delay, map, tap } from 'rxjs/operators';
import { Ticket, User } from './ticket.interface';
function randomDelay() {
return Math.random() * 4000;
}
@Injectable()
export class TicketService {
storedTickets: Ticket[] = [
{
id: 0,
description: 'Install a monitor arm',
assigneeId: 111,
completed: false
},
{
id: 1,
description: 'Move the desk to the new location',
assigneeId: 111,
completed: false
},
{
id: 2,
description: 'This ticket assignee has been deleted',
assigneeId: 112,
completed: false
}
];
storedUsers: User[] = [{ id: 111, name: 'Victor' }];
lastId = 1;
private findTicketById = id => this.storedTickets.find(ticket => ticket.id === +id);
private findUserById = id => this.storedUsers.find(user => user.id === +id);
tickets() {
return of(this.storedTickets).pipe(delay(randomDelay()));
}
ticket(id: number): Observable<Ticket> {
return of(this.findTicketById(id)).pipe(delay(randomDelay()));
}
users() {
return of(this.storedUsers).pipe(delay(randomDelay()));
}
user(id: number) {
const user = this.findUserById(id);
return user ? of(user).pipe(delay(randomDelay())) : throwError('User not found');
}
newTicket(payload: { description: string }) {
const newTicket: Ticket = {
id: ++this.lastId,
description: payload.description,
assigneeId: null,
completed: false
};
return of(newTicket).pipe(
delay(randomDelay()),
tap((ticket: Ticket) => this.storedTickets.push(ticket))
);
}
assign(ticketId: number, userId: number) {
const foundTicket = this.findTicketById(+ticketId);
const user = this.findUserById(+userId);
if (foundTicket && user) {
return of(foundTicket).pipe(
delay(randomDelay()),
map((ticket: Ticket) => {
ticket.assigneeId = +userId;
return ticket;
})
);
}
return throwError(new Error('ticket or user not found'));
}
complete(ticketId: number, completed: boolean) {
const foundTicket = this.findTicketById(+ticketId);
if (foundTicket) {
return of(foundTicket).pipe(
delay(randomDelay()),
map((ticket: Ticket) => {
ticket.completed = completed;
return ticket;
})
);
}
return throwError(new Error('ticket not found'));
}
}
Now what we want to achieve is to add a state manager on top of this service. Let's start:
npm i --save @microphi/store
Create some interfaces that will be useful later:
export interface User {
id: number;
name: string;
}
export interface Ticket {
id: number;
description: string;
assigneeId: number;
completed: boolean;
assignee?: User;
}
export type TicketWithState = Ticket & { isLoading?: boolean };
export interface TicketsState {
tickets: TicketWithState[];
}
Now let's create an enum with all the actions our store will handle.
export enum TicketActions {
FIND_ALL, // will retrive all tickets
FIND_ONE, // will retrive one ticket
CHANGE_STATUS // will change the status done/undone of a ticket
}
Let's create our store
// ticket.store.ts
@Store({
name: 'ticketStore',
initialState: { tickets: [] },
actions: TicketActions
})
@Injectable()
export class TicketStore extends BaseStore<TicketsState> {
public tickets$ = this.store$.pipe(
map((state) => state.tickets)
);
constructor(private ticketService: TicketService) {
super();
}
[...]
Within the @Store
decorator we define the name
of the store, it's initial state and we provide the enum of our actions.
One thing to notice here is that this.store$
is an observable on which internally every and each new store state will be next
ed. It's defined in the parent BaseStore
class and there it's protected so that we always need to map
it somehow when we want to expose it. And of course we're gonna need the TicketService.
Before we go further a bit of philosophy. Bear with me though because what follows is not about Redux/Flux neither about ngrx. It is instead my interpretation of the above to make it easier to digest and to use.
The approach is as follows:
somewhere in our app -> action -> effect -> stateupdate
Simply somewhere in our app we dispatch an action which will trigger an (optional) effect which will trigger a state change. That state change will be "visible" to everyone that subscribed to the tickets$
observable, for example.
Bear in mind that asynchronous stuff needs to be handled in an effect
.
Right, now: before we start writing any effect let's set up our component so that we get the tickets$
.
@Component({
selector: 'app-tickets',
templateUrl: './tickets.component.html'
})
export class TicketsComponent implements OnInit {
public tickets$ = this.store.tickets$;
constructor(private store: TicketStore) { }
public ngOnInit(): void {
this.store.dispatch(TicketActions.FIND_ALL);
}
}
What we need now to create an effect to retrive the list of tickets from our backend.
// ticket.store.ts
@Effect(TicketActions.FIND_ALL)
private getTickets(state: Ticket[], payload) {
let numberOfTickets = 0;
return this.ticketService.tickets().pipe(
switchMap((tickets) => {
console.log('parsing tickets', tickets);
numberOfTickets = tickets.length;
return from(tickets);
}),
tap((ticket) => {
console.log('parsing ticket', ticket);
}),
mergeMap((ticket: Ticket) => {
return this.ticketService.user(ticket.assigneeId).pipe(
map((user) => {
ticket.assignee = user;
return ticket;
}),
catchError(err => {
console.error(err);
// silently fail
ticket.assignee = {
name: `unable to find user with id ${ticket.assigneeId}`,
id: ticket.assigneeId
};
return of(ticket);
})
);
}),
bufferCount(numberOfTickets)
);
}
Define a method decorated with @Effect(actionName)
. This function will be invoked whenever the given actionName
is dispatched.
Methods decorated with @Effect
must return an observable!
tickest()
returns an array of tickets which is switchMap
ed into each element. Then we mergeMap
it with the user associated with the assigneeId
field if it exists; if not just silently fail. Each element is bufferCount
ed so that the observable returns an array.
In case of error we swallow it and return the original ticket with a convenience user attached to it.
Of course it can be arguable to fetch the tickets in a different way depending of what you actually want to achieve so bear with this code for demonstration purpose.
At this point we dispatched the FIND_ALL
event internally the getTickets
method is invoked and it's return value gets subscribed to. Once a value gets through we will next the actions$
observable again this time with a "complementary" action which will trigger the reducing method associated with FIND_ALL
.
Let's write our reducer:
// ticket.store.ts
@Reduce(TicketActions.FIND_ALL)
private onResponse(state, payload: Ticket[]) {
// REM:
// initial state would be { tickets: [] }
// the payload is the data coming through from the associated @Effect[ed] method
this.state.tickets.push(...payload);
return state;
}
Internally the framework checks for the method associated to this action, i.e.: onResponse
, invokes it and its return value is set into the _state
private property through a setter which will trigger a store$.next
with it.
And now you only need to use an async pipe in your view in order to see the tickets magially appear on the screen.
<pre>
{{tickets$ | async | json}}
</pre>
Hint: if you're running the example app from the repo try to open the console and type: localStorege.debug = 'microphi:*'
and you'll see some of the magic that is happening.
Cool, but what about loading state?
The store has a public property called loading$
that is is nexted with and event containing three fields type
, payload
and status
. Obviously status
will tell us whether loading is in process being true
or false
. type
will contain information about the event being loading and the payload is the payload we passed to the dispatch
method.
So for example if you want to catch any loading event going on within your effects you can just do.
// tickets.component.ts
constructor(private ticketStore: TicketStore) {
this.ticketStore.loading$.subscribe((status) => {
// we can't use an async pipe for this as it would subscribe to the observable after ngOnInit hence we miss
// the loading start event;
this.loading = status.status;
});
Or we may want to filter out only the loading for a specific event
constructor(private authStore: AuthStore, private ticketStore: TicketStore) {
this.ticketStore.loading$.pipe(
filter((event) => {
return event.type === this.ticketStore.getRequestFromAction(TicketActions.FIND_ALL);
})
).subscribe((status) => {
// we can't use an async pipe for this as it would subscribe
// to only after ngOnInit hence we miss the loading
// start event
this.loadingTickets = status.status;
});
Here getRequestFromAction
is an helper function to map the FIND_ALL
action to its "complementary" action.
That's all for now folk.
Let me know what you think in the comment below.
Top comments (0)