Angular & NgRx

Ahsan Ayaz

Ahsan Ayaz

Senior Software Engineer

What is NgRx?

Our focus today : @ngrx/store

But WHY?

Data Flow & State Management

Every component interacts with different data sets i.e. different states and it gets very messy as the project grows.

Without @ngrx/store

Demo

<ar-home>


    // chat page
    <div id="homePage">
      <div class="chat-container">
        <button class="btn btn-icon btn-link" [routerLink]="['/']">
          <i class="glyphicon glyphicon-chevron-left"></i> Go Back
        </button>
        <ar-chat></ar-chat>        // chat component
      </div>
    </div>
    

    import { Component, OnInit } from '@angular/core';

    @Component({
      selector: 'ar-home',
      templateUrl: './home.component.html',
      styleUrls: ['./home.component.scss']
    })
    export class HomeComponent implements OnInit {
    
      constructor() { }
    
      ngOnInit() {
    
      }
    }

<ar-chat>

    // ar-chat component
    <h3 class="heading text-center">Angular4 Rockstar Chat</h3>
    <ar-chat-messages [messages]="chatMessages"></ar-chat-messages>
    <ar-write-message (onMessageSent)="newMessageSent($event)"></ar-write-message>

    export class ChatComponent implements OnInit {
      chatMessages: Array<Message> = [];
      constructor(private chatService: ChatService) { }
    
      ngOnInit() {
        // get messages on component init
        this.chatService.getMessages()
          .subscribe((messages: Array<Message>) => {
            this.chatMessages = messages;
          });
      }
    
      /**
       * @author Ahsan Ayaz
       * Handler for a new message creation. Pushes the new message to the message list
       * @param message {Message}
       */
      newMessageSent(message: Message) {
        this.chatMessages.push(message);
      }
    
    }

<ar-chat-messages>

    

    // ar-chat-messages component
    <div class="messages-list" #messagesList>
      <div *ngFor="let message of messages" class="message-item">
        <div class="from-user">
          <img [src]="message.userImage">
        </div>
        <div class="message-text">{{message.text}}</div>
        <div class="delete-btn btn btn-sm btn-danger" (click)="removeMessage(message)">x</div>
      </div>
    </div>


    export class ChatMessagesComponent implements OnInit {
      
      @Input('messages') public messages: Array<Message>;
      constructor() { }
    
      ngOnInit() {
          this.scrollToBottom();  // scroll to bottom on component init
      }
    
      removeMessage(message) {
        this.messages.splice(this.messages.indexOf(message), 1);
      }
    }

<ar-write-message>

    // ar-write-message component
    <div class="write-box-container">
      <div class="write-box">
        <textarea placeholder="Enter your message here. Press shift + enter for new line" 
            class="chat-input" [(ngModel)]="chatInput" (keyup.enter)="sendMessage()">
        </textarea>
      </div>
      <div class="send-button">
        <button [disabled]="!chatInput || !chatInput.trim()" 
            class="btn btn-primary" (click)="sendMessage()">
          <i class="glyphicon glyphicon-send"></i>
        </button>
      </div>
    </div>
    export class WriteMessageComponent {
      @Output() public onMessageSent = new EventEmitter<any>();
      public chatInput = '';
      constructor(private chatService: ChatService) { }
      /**
       * @author Ahsan Ayaz
       * Creates a new message and emits to parent component
       */
      sendMessage() {
        const id = Math.ceil(Math.random() * 1000 + 1);
        const message: Message = this.chatService.processMessages(
          [{id: id, text: this.chatInput}]
        )[0];
        this.onMessageSent.emit(message);
        this.chatInput = '';
      }
    }

Classic app flow

  • The ar-chat component loads the messages and passes chatMessages to the ar-chat-messages component as @Input.
  • ar-chat-messages component shows the messages list according to the messages input.
  • ar-write-message component creates a new message, triggers the EventEmitter using @Output
  • ar-chat listens for the above mentioned EventEmmitter i.e. onMessageSent and pushes to chatMessages which in turn updates the messages input in ar-chat-messages this refreshing the view

Redux & NgRx to the rescue

NgRx/store components

  • State
    • A centralized object which is an immutable data structure
  • Actions
    • Component interactions that cause changes in state
  • Reducers
    • Functions that change the state according to specific actions and return new state
  • Store
    • A centralized database that contains state and works as an Observable

With @ngrx/store

Using the STORE

  • We can do `store.dispatch` to dispatch actions
    • Since it is the only way to change state, it is easier to track
  • We can select any part of the State or the whole state using `store.select`
    • Single source of truth, the only way to get the state objects

Demo

<ar-chat>

    // ar-chat component
    <h3 class="heading text-center">Angular4 Rockstar Chat - NgRx</h3>
    <ar-chat-messages></ar-chat-messages>    // removed [messages]="chatMessages" @Input
    <ar-write-message></ar-write-message>    // removed (onMessageSent) @Output

    export class ChatComponent implements OnInit {
      constructor(private chatService: ChatService, private store: Store<any>) { }
    
      ngOnInit() {
        this.loadMessages();
      }
    
      /**
       * @author Ahsan Ayaz
       * This function below fetches the initial messages from the ChatService
       * Currently the dummy messages
       */
      loadMessages(): void{
        this.chatService.getMessages()
          .subscribe((messages: Array<Message>) => {
            this.store.dispatch({
              type: 'LOAD_MESSAGES',
              payload: messages
            });
          });
      }

<ar-chat-messages>

    
    // ar-chat-messages component
    <div class="messages-list" #messagesList [style.display]="messagesLength? 'block' : 'none'">
      <div *ngFor="let message of messages$ | async" 
        class="message-item" [ngClass]="{mine: message.userId === myId}">
        <div class="from-user">
          <img [src]="message.userImage">
        </div>
        <div class="message-text">{{message.text}}</div>
        <div class="delete-btn btn btn-sm btn-danger" (click)="removeMessage(message)">x</div>
      </div>
    </div>

    export class ChatMessagesComponent implements OnInit {
      public messages$;
      constructor(private store: Store<any>) {}
    
      ngOnInit() {
        this.fetchMessages();                     // fetch the messages from store
      }
    
      /**
       * @author Ahsan Ayaz
       * The following function fetches the messages from the Store
       */
      fetchMessages(): void {
        this.messages$ = this.store.select('messages');
      }
    }

<ar-write-message>

    // ar-write-message component
    <div class="write-box-container">
      <div class="write-box">
        <textarea placeholder="Enter your message here. Press shift + enter for new line" 
        class="chat-input" [(ngModel)]="chatInput" (keyup.enter)="sendMessage()"></textarea>
      </div>
      <div class="send-button">
        <button [disabled]="!chatInput || !chatInput.trim()" class="btn btn-primary" (click)="sendMessage()">
          <i class="glyphicon glyphicon-send"></i>
        </button>
      </div>
    </div>
    export class WriteMessageComponent implements OnInit {
      public chatInput = '';
      constructor(private chatService: ChatService, private store: Store<any>) { }
    
      ngOnInit() {
 
      /**
       * @author Ahsan Ayaz
       * Creates a new message and emits to parent component
       */
      sendMessage() {
        const id = Math.ceil(Math.random() * 1000 + 1);
        const message: Message = this.chatService.processMessages([{id: id, text: this.chatInput}])[0];
        this.store.dispatch({
          type: 'ADD_MESSAGE',
          payload: message
        });
        this.chatInput = '';
      }
    }

message reducer


    import { ActionReducer, Action } from '@ngrx/store';
    
    export const ADD_MESSAGE = 'ADD_MESSAGE';
    export const REMOVE_MESSAGE = 'REMOVE_MESSAGE';
    export const REMOVE_ALL = 'RESET';
    export const LOAD_MESSAGES = 'LOAD_MESSAGES';
    
    export const messages: ActionReducer<any> = (state = [], action: Action) => {
        switch (action.type) {
            case ADD_MESSAGE:
                return [
                    ...state,
                    action.payload
                ];
            case REMOVE_MESSAGE:
                return state.filter((message) => {
                    return message.id !== action.payload;
                });
            case REMOVE_ALL:
                return state;
            case LOAD_MESSAGES:
                return [
                    ...action.payload
                ];
            default:
                return state;
        }
    };

messageFilter reducer

 

    import { ActionReducer, Action } from '@ngrx/store';
    
    export const SHOW_ALL = 'SHOW_ALL';
    export const SHOW_MINE = 'SHOW_MINE';
    
    
    // because we have to persist the filter
    let lastAction: Action = {
        type: '',
        payload: null
    };
    
    export const messagesFilter: ActionReducer<any> = (state = message => message, action: Action) => {
        lastAction = (action.type === SHOW_ALL || action.type === SHOW_MINE) ? action : lastAction;
        switch (lastAction.type) {
            case SHOW_ALL:
                return message => message;
            case SHOW_MINE:
                return message => message.userId === lastAction.payload;
            default:
                return message => message;
        }
    };

Joining Reducers

    
    // ar-chat-messages component
    <div class="messages-list" #messagesList [style.display]="messagesLength? 'block' : 'none'">
      <div *ngFor="let message of messages$ | async" class="message-item" [ngClass]="{mine: message.userId === myId}">
        <div class="from-user">
          <img [src]="message.userImage">
        </div>
        <div class="message-text">{{message.text}}</div>
        <div class="delete-btn btn btn-sm btn-danger" (click)="removeMessage(message)">x</div>
      </div>
    </div>

      

      /**
       * @author Ahsan Ayaz
       * The following function fetches the messages from the Store
       */
      fetchMessages(): void {
        this.messages$ = Observable.combineLatest(
          this.store.select('messages'),
          this.store.select('messagesFilter'),
          (messages: any, filter: any) => {
            const filteredMessages = messages.filter(filter);
            this.messagesLength = filteredMessages.length;
            this.scrollToBottom(100);  // scroll to bottom after content changed
            return filteredMessages;
          }
        ).distinctUntilChanged();
      }

Side Effects

Logic that behaves outside @ngrx/store

Hard to test. And again... the classic Angular stuff

<ar-chat> (with side-effect)

 
    export class ChatComponent implements OnInit {
      constructor(private chatService: ChatService, private store: Store<any>) { }
    
      ngOnInit() {
        this.loadMessages();
      }
    
      /**
       * @author Ahsan Ayaz
       * This function below fetches the initial messages from the ChatService
       * Currently the dummy messages
       */
      loadMessages(): void{
        this.chatService.getMessages()                // SIDE EFFECT
          .subscribe((messages: Array<Message>) => {
            this.store.dispatch({
              type: 'LOAD_MESSAGES',
              payload: messages
            });
          });
      }

How will we get rid of the Side Effects?

Not exactly !

We have @ngrx/effects to the rescue

ChatEffects (@Effect)

    

    import { Injectable } from '@angular/core';
    import { Actions, Effect } from '@ngrx/effects';
    import { Observable } from 'rxjs/Observable';
    import { ChatService } from '../providers/chat.service';
    
    @Injectable()
    export class ChatEffects {
        @Effect() getMessages$ = this.actions$
            // Listen for the 'LOAD_MESSAGES' action
            .ofType('LOAD_MESSAGES')
            // Map the payload into JSON to use as the request body
            .map(action => JSON.stringify(action.payload))
            .switchMap(payload => this.chatService.getMessages()
            // If successful, dispatch success action with result
            .map(res => ({ type: 'LOAD_MESSAGES_SUCCESS', payload: res }))
            // If request fails, dispatch failed action
            .catch(() => Observable.of({ type: 'LOAD_MESSAGES_FAILURE', payload: [] }))
            );
        constructor(
            private chatService: ChatService,
            private actions$: Actions
        ) { }
    }

<ar-chat> (no side-effect)


    import { Component, OnInit } from '@angular/core';
    import { Message } from '../../models/message';
    import { Store } from '@ngrx/store';
    @Component({
      selector: 'ar-chat',
      templateUrl: './chat.component.html',
      styleUrls: ['./chat.component.scss']
    })
    export class ChatComponent implements OnInit {
      constructor(private store: Store<any>) { }
    
      ngOnInit() {
        this.loadMessages();
      }
    
      /**
       * @author Ahsan Ayaz
       * This function below dispatches the LOAD_MESSAGES action which 
       * fetches the message in the store
       * Currently the dummy messages
       */
      loadMessages(): void {
        // we are just dispatching an action here. I.e. eliminated the side-effect

        this.store.dispatch({
          type: 'LOAD_MESSAGES'
        });
      }
    }

What do we achieve using Effects?

  • SINGLE source of truth. I.e. the STORE
  • Less/No responsibility on the Components except to work with view models
  • Easier testing

@ngrx-devtools

Demo

More stuff to read & learn

Links of demo apps

Questions?

Thank You!

Ahsan Ayaz

Senior Software Engineer

Angular & NgRx

By Ahsan Ayaz

Angular & NgRx

A talk presented for Angular Pakistan meet up #3. Revolves around NgRx and how it makes Angular more awesome to work with

  • 1,310
Loading comments...

More from Ahsan Ayaz