Go Reactive with NgRx

What you'll learn today

  • Understanding of a set of extensions from NgRx
  • Hands-on live workshop to convert a simple Angular application to a reactive app with NgRx.
  • Testing/debugging NgRx based Angular applications with some cool developer tools. 

echo `whoami`

Sr. Full Stack Engineer

Ahsan Ayaz

Follow along the workshop:

  • Open terminal ( or command prompt )
  • Execute the following commands:
    git clone https://github.com/AhsanAyaz/ngrx-workshop.git
  • cd <folder-name>
  • yarn or npm install
  • yarn start or npm start
  • Navigate to http://localhost:4200

Workshop Rules

  • Clone (or better fork) the repository 
  • You Do Not need to copy any code from the slide
  • Just check out the branch on each step and uncomment the chunks from the files that I have shown in the slides.

What is NgRx?

Why NgRx?

  • Easier application state management
  • Easier testing with less responsibilities on components
  • Debugging application state with dev-tools

App Components

Notes Header

Notes List

Note Editor

Notes Component

Problem ? 

Lots of inter-component communication

Application state is hard to manage as it scales

Flow control becomes a pain in large scale apps

Notes Header

Notes

Notes List

Add New Note

Example: 

<!-- Notes Component HTML (notes.component.html) --> 

<div class="notes-app container">
    <app-notes-header
        (onDeleteNote)="deleteNote($event)"
        (onAddNote)="addNewNote($event)"
        [callInProgress]="callInProgress"
        [activeNote]="activeNote">
    </app-notes-header>
    <div class="notes-app-content">
        <app-notes-list
            (onNoteClicked)="setActiveNote($event, true)"
            [notes]="notesList"
        </app-notes-list>
        <app-notes-editor
          (onTextFocus)="unselectActiveNote()"
          (onTextChanged)="updateNote($event)"
          [note]="activeNote">
        </app-notes-editor>
    </div>
</div>
// Notes Header Component (notes-header.component.ts)

export class NotesHeaderComponent implements OnInit {
  @Input() activeNote: Note;
  @Input() callInProgress: boolean;
  @Output() onAddNote = new EventEmitter<Note>();
  @Output() onDeleteNote = new EventEmitter<Note>();
  constructor() { }

  ngOnInit() {
  }

  /**
   * @author Ahsan Ayaz
   * @desc Triggers when the add button is clicked.
   * It emits a new note above the component tree
   */
  addNote() {
    this.onAddNote.emit({
      text: '',
      cts: new Date(),
      active: false,
      selected: false
    });
  }
}
// Notes Component (notes.component.ts)

  /**
   * @author Ahsan Ayaz
   * @desc Adds the passed note to the list at top.
   * Also selects the added note
   * @param note - the note to be added to the notes list
   */
  addNewNote(note: Note) {
    this.callInProgress = true;
    this.notesService.addNewNote(note)
      .first()
      .subscribe((savedNote: Note) => {
        this.notesList.unshift(savedNote);
        this.setActiveNote(this.notesList[0]);
        this.callInProgress = false;
      });
  }

Pretty Simple. Right ?

Notes

Notes Header

Notes List

Note Editor

Add Notes Click

Note Selection

Text
Update

Possible communications via Services etc

Solution ?

A

C

B

Communication Story

+

=

+

How is this different ?

Let's understand Redux

Reducer

Components

Dispatch Action

Store / State

The state is immutable

user: { name: "ahsan" }

App State

Component 1

Component 2

Component 3

Dispatches action

user: { name: "ahsan ayaz" }

NgRx State

interface UserState {
    name: string;
}

interface AppState{
    user: UserState;
    todos: Array<string>;
}


const initialState: AppState = {
    user: {
        name: ""
    },
    todos: []
}

A state is just a simple javascript object. But you can't access nor change it directly.

NgRx Actions

interface Action {
  type: string;
  payload?: any;
}
const action = {   
    type: "CHANGE_NAME",
    payload: {
        name: "Ahsan Ayaz"
    }
}
this.store.dispatch(action);

Actions are the only way to change the state.
They are just javascript objects with a type and (optional) payload.

NgRx Reducers

interface Reducer<State>{
    (state: State, action: Action):State
}
function appReducer(
    state: AppState = initialState,
    action: Action
): State {
    switch(action){
        case "CHANGE_NAME":
            return {
                ...state,
                ...{
                    user: {
                       name: action.payload.name
                    }
                }
            };

        default:
            return state;
    }
}

Reducers are pure functions which act upon action and change the state.

Returning a new object

NgRx Store




interface Store<State> extends Observable<State>{
    dispatch(action: Action): void;
    select<T>(selectorFunc: (value: State) => T): Observable<T>;
}

A single source of truth for application's state. Components can subscribe to the state (or the chunk of the state) they're interested in.

NgRx Effects & Side Effects

Reducer

Components

Dispatch Action

Store / State

Side
Effect

Server
Call

Dispatch Action

@Injectable()
export class UserEffects {
    @Effect() $getUser: Observable<Action> = this.actions$.ofType(GET_USER)
    .map(toPayload)
    .mergeMap(payload => {
        return this.http.get('my_url/getUsers')
        .map(response => response.json())
        .map((data: any) => {
            return { type: "GET_USER_SUCCESS", payload: data };
        })
        .catch(() => {
            return of({ type: "GET_USER_ERROR" });
        })
    });

    constructor(
        private http: Http,
        private actions$: Actions
    ) {}
}

NgRx Dev-tools

A set of amazing dev tools for developing apps with NgRx.

  • Viewing Actions dispatched
  • Viewing State changes
  • Time Travel Debugger

Enough talk. Let's dive in the code

Step 1

Install the dependencies & set up NgRx

git checkout step1-ngrx-setup
npm install @ngrx/store @ngrx/store-devtools @ngrx/effects --save
// [FILE] = src/app/app.module.ts

import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { notesReducer } from './store/notes/notes.reducer';


@NgModule({
  declarations: [
    ...
  ],
  imports: [
    ...
    StoreModule.forRoot({ notes: notesReducer }),
    StoreDevtoolsModule.instrument({
      maxAge: 25 //  Retains last 25 states
    }),
    ...
  ]
  ...
});  

Step 2

Adding actions, Notes List & Notes Editor

git checkout step2-act-red-sel 
// [FILE] = src/app/store/notes/notes.actions.ts

import { Action } from '@ngrx/store';

export const GET_NOTES =                        '[Notes] GetNotes';

export class GetNotes implements Action {
  readonly type = GET_NOTES;
}

export type Actions
= GetNotes;

Set up actions

// [FILE] = src/app/store/notes/notes.reducer.ts

import * as NotesActions from './notes.actions';

export function notesReducer(
  state: NotesState = initialState,
  action: NotesActions.Actions
): NotesState {
    switch (action.type) {
      case NotesActions.GET_NOTES:
        return {
          ...state,
          ...{ notesList: dummyData }
        };
      default:
        return state;
    }
}

Set up reducer to handle actions

// [FILE] = src/app/store/notes/notes.selectors.ts

import { createFeatureSelector, createSelector } from '@ngrx/store';
import { NotesState, initialState } from './notes.reducer';
import { Note } from '../../models/note';
export const selectNotes = createFeatureSelector<NotesState>('notes');

export const selectNotesList = createSelector(
  selectNotes, (state: NotesState = initialState): Array<Note> => state.notesList
);

export const selectActiveNote = createSelector(
  selectNotes, (state: NotesState = initialState): Note => state.activeNote
);

Set up selectors that we'll use to subscribe elements from the store 

// [FILE] = src/app/notes-list/notes-list.component.ts

import { Observable } from 'rxjs/Observable';
import { Store } from '@ngrx/store';
import { NotesState } from '../store/notes/notes.reducer';
import { selectNotesList, selectActiveNote } from '../store/notes/notes.selectors';
import { SetActiveNote } from '../store/notes/notes.actions';

  
  $notesList: Observable<Array<Note>>;
  $activeNote: Observable<Note>;
  constructor(
    private store: Store<NotesState>
  ) {
  }

  ngOnInit() {
    this.$notesList = this.store.select(selectNotesList);
  }

Select the notes list in notes list component

// [FILE] = src/app/notes/notes.component.ts

import { Store } from '@ngrx/store';
import { NotesState } from '../store/notes/notes.reducer';
import { GetNotes, SetActiveNote } from '../store/notes/notes.actions';

@Component({
  selector: 'app-notes',
  templateUrl: './notes.component.html',
  styleUrls: ['./notes.component.scss']
})
export class NotesComponent implements OnInit {
  constructor(
    private store: Store<NotesState>
  ) { }

  ngOnInit() {
    this.store.dispatch(new GetNotes());
  }
}

Get the notes from dispatching an action from notes component

// [FILE] = src/app/notes/notes.component.ts

this.store.dispatch(new SetActiveNote({}));


// [FILE] = src/app/notes-list/notes-list.component.ts
ngOnInit() {
    this.$activeNote = this.store.select(selectActiveNote);
}

showNote(note: Note) {
    this.store.dispatch(new SetActiveNote({
        note
    }));
}

Set active note to show in editor

// [FILE] = src/app/notes-editor/notes-editor.component.ts

import { Note } from '../models/note';
import { Observable } from 'rxjs/Observable';
import { Store } from '@ngrx/store';
import { NotesState } from '../store/notes/notes.reducer';
import { selectActiveNote } from '../store/notes/notes.selectors';

export class NotesEditorComponent implements OnInit {
  $activeNote: Observable<Note>;
  constructor(
    private store: Store<NotesState>
  ) { }

  ngOnInit() {
    this.$activeNote = this.store.select(selectActiveNote);
  }
}

Subscribe to the active note in the editor

Step 3

Adding & Deleting Notes

git checkout step3-add-delete-notes
// [FILE] = src/app/notes-header/notes-header.component.ts

import { Store } from '@ngrx/store';
import { NotesState } from '../store/notes/notes.reducer';
import { Observable } from 'rxjs/Observable';
import { SetActiveNote, AddNote, DeleteNote } from '../store/notes/notes.actions';
import { selectActiveNote, selectCallInProgress } from '../store/notes/notes.selectors';

export class NotesHeaderComponent implements OnInit {
  $callInProgress: Observable<boolean>;
  $activeNote: Observable<Note>;
  constructor(
    private store: Store<NotesState>
  ) { }

  ngOnInit() {
    this.$activeNote = this.store.select(selectActiveNote);
    this.$callInProgress = this.store.select(selectCallInProgress);
  }
}

Subscribe to the active note

// [FILE] = src/app/notes-header/notes-header.component.ts

import { Store } from '@ngrx/store';
import { NotesState } from '../store/notes/notes.reducer';
import { Observable } from 'rxjs/Observable';
import { SetActiveNote, AddNote, DeleteNote } from '../store/notes/notes.actions';
import { selectActiveNote, selectCallInProgress } from '../store/notes/notes.selectors';

export class NotesHeaderComponent implements OnInit {
  addNote() {
    this.store.dispatch(new AddNote({
      note: {
        text: '',
        _id: Math.ceil(Math.random() * 300).toString(),
        cts: new Date(),
        active: false,
        selected: false
      }
    }));
    this.store.dispatch(new SetActiveNote({}));
  }

  deleteSelectedNote() {
    this.store.dispatch(new DeleteNote());
    this.store.dispatch(new SetActiveNote({}));
  }  
}

Dispatch the Add & Delete note actions

Step 4

Adding Effects to interact with the API

git checkout step4-adding-effects 
// [FILE] = src/app/store/notes/notes.actions.ts

export const GET_NOTES =                        '[Notes] GetNotes';
export const GET_NOTES_SUCCESS =                '[Notes] GetNotesSuccess';
export const GET_NOTES_FAILURE =                '[Notes] GetNotesFailure';

export class AddNote implements Action {
  readonly type = ADD_NOTE;

  constructor(public payload: { note: Note }) { }
}
export class AddNoteSuccess implements Action {
    readonly type = ADD_NOTE_SUCCESS;

    constructor(public payload: { note: Note }) { }
}
export class AddNoteFailure implements Action {
    readonly type = ADD_NOTE_FAILURE;

    constructor(public payload: { error: string }) { }
}

export type Actions
= AddNote
| AddNoteSuccess
| AddNoteFailure

Adding API handler actions

// [FILE] = src/app/store/notes/notes.reducer.ts

export function notesReducer(
  state: NotesState = initialState,
  action: NotesActions.Actions    // USE NotesActions.Actions
): NotesState {
    switch (action.type) {
      case NotesActions.GET_NOTES:
        return {
          ...state,
          callInProgress: true
        };
      case NotesActions.GET_NOTES_SUCCESS:
        return {
          ...state,
          ...{ notesList: action.payload.notes, callInProgress: false }
        };
      case NotesActions.GET_NOTES_FAILURE:
        return {
          ...state,
          ...{ callInProgress: false }
        };
    }
}

Changing state on API handler actions in notesReducer

// [FILE] = src/app/store/notes/notes.effects.ts


import { Actions, Effect, toPayload } from '@ngrx/effects';
import * as NotesActions from './notes.actions';
import { NotesService } from '../../services/notes.service';

@Injectable()
export class NotesEffects {
  /**
   * @author Ahsan Ayaz
   * @desc Fetches the notes from the server
   */
  @Effect() $getNotes = this.actions$.ofType(NotesActions.GET_NOTES)
    .map(toPayload)
    .mergeMap(payload => {
      return this.notesService.getNotes();
    })
    .mergeMap(notes => {
      // on success, dispatch success and set first item active
      return [
        new NotesActions.GetNotesSuccess({
          notes
        }),
        new NotesActions.SetActiveNote({})
      ];
    })
    .catch(() => {
      return Observable.of(new NotesActions.GetNotesFailure({
        error: 'Could not fetch notes'
      }));
  });

Create EFFECTS

// [FILE] = src/app/app.module.ts


import { EffectsModule } from '@ngrx/effects';
import { NotesEffects } from './store/notes/notes.effects';


@NgModule({
  ...
  imports: [
    ...,
    EffectsModule.forRoot([NotesEffects]),
    ...
  ],
  ...
})

Adding Effect to App Module

Step 5

Saving/Updating Notes as you type

git checkout step5-updating-notes
// [FILE] = src/app/store/notes/notes.actions.ts

export const UPDATE_NOTE =                '[Notes] UpdateNote';
export const UPDATE_NOTE_SUCCESS =        '[Notes] UpdateNoteSuccess';
export const UPDATE_NOTE_FAILURE =        '[Notes] UpdateNoteFailure';


export class UpdateNote implements Action {
  readonly type = UPDATE_NOTE;

  constructor(public payload: { note: Note }) { }
}

export class UpdateNoteSuccess implements Action {
    readonly type = UPDATE_NOTE_SUCCESS;

    constructor(public payload: { note: Note }) { }
}

export class UpdateNoteFailure implements Action {
    readonly type = UPDATE_NOTE_FAILURE;

    constructor(public payload: { error: string }) { }
}

export type Actions
= UpdateNote
| UpdateNoteSuccess
| UpdateNoteFailure

Adding Update Note actions

// [FILE] = src/app/store/notes/notes.reducer.ts



  case NotesActions.UPDATE_NOTE:
    return {
      ...state,
      ...{ callInProgress: true }
    };

  case NotesActions.UPDATE_NOTE_SUCCESS:
  case NotesActions.DELETE_NOTE_FAILURE:
    return {
      ...state,
      ...{ callInProgress: false }
    };

Changing state on Update actions

// [FILE] = src/app/store/notes/notes.effects.ts

  /**
   * @author Ahsan Ayaz
   * @desc Updates the active note and sends to the server
   */
  @Effect() $updateNote = this.actions$.ofType(NotesActions.UPDATE_NOTE)
  .map(toPayload)
  .debounceTime(300)
  .mergeMap(payload => {
    return this.notesService.updateNote(payload.note);
  })
  .switchMap(note => {
    // on success, dispatch success and set first item active
    return [
      new NotesActions.UpdateNoteSuccess({
        note
      })
    ];
  })
  .catch(() => {
    return Observable.of(new NotesActions.UpdateNoteFailure({
      error: 'Could not update note'
    }));
  });

Create Update Effect

// [FILE] = src/app/notes-editor/notes-editor.component.ts

import { UpdateNote } from '../store/notes/notes.actions';

onInputChange(noteText: string = '') {
    this.$activeNote
      .first()
      .subscribe(note => {
        this.store.dispatch(new UpdateNote({
          note
        }));
      });
  }

Dispatch the Update action on text input change

Questions?

Thank You!

Go Reactive with NgRx

By Ahsan Ayaz

Go Reactive with NgRx

A presentation (with a workshop) regarding how to make your Angular app go completely reactive with NgRx. This includes ngrx/store, ngrx/effects & ngrx/dev-tools

  • 773
Loading comments...

More from Ahsan Ayaz