DEV Community

Davide Cavaliere
Davide Cavaliere

Posted on

A State Manager For Angular That Makes Sense, At Least For Me :)

To Redux or Not To Redux

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'));
  }
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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[];
}


Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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();
  }

[...]

Enter fullscreen mode Exit fullscreen mode

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 nexted. 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

Enter fullscreen mode Exit fullscreen mode

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);
  }
}


Enter fullscreen mode Exit fullscreen mode

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)
    );
  }

Enter fullscreen mode Exit fullscreen mode

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 switchMaped 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 bufferCounted 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;
  }

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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;

    });

Enter fullscreen mode Exit fullscreen mode

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;

    });

Enter fullscreen mode Exit fullscreen mode

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)