State management in Angular with ngrx

Gabriel Katz

Content

  • Some Prerequisites
  • ngrx store and how to make it work
  • Advanced ngrx topics
  • Pros & Cons

Prerequisites - RXJS

Primitives

  • Observable: stream of events
  • Subject: Observable of which input can be controlled
  • BehaviorSubject: Subject that gives the subscribers its current state first

Prerequisites - Important RXJS Operators

map: Apply function to each emitted value.

filter: Only return emitted values that fulfill predicate.

scan: Like a reduce, but emits intermediate result.

Prerequisites - Important RXJS Operators

mergeMap: Create observable for each emitted value, and merge observables

Prerequisites - Important RXJS Operators

switchMap: Only listen to observable created from most current emitted value

Prerequisites - State in angular Applications

What is ngrx?

  • A state container for angular
  • Implementation of the flux pattern
  • Similar to redux, but implemented using rxjs

Issue with "Javascript MVC"

From https://brigade.engineering/what-is-the-flux-application-architecture-b57ebca85b9e

Flux pattern

From https://brigade.engineering/what-is-the-flux-application-architecture-b57ebca85b9e

Bring the complexity!

From https://brigade.engineering/what-is-the-flux-application-architecture-b57ebca85b9e

What the store (more or less) does

export type Reducer<S, A> = (state: S, action: A) => S;

@Injectable()
export class Store<S, A> extends BehaviorSubject<S>{
  
  private actions = new Subject<A>();

  constructor(private reducer: Reducer<S, A>, public initialState: S) {
    super(initialState);
    this.actions.scan((state, action) => this.reducer(state, action), initialState)
      .subscribe(super);
  }

  dispatch(action: A): void {
    this.actions.next(action);
  }
}

Parts you need to write to make ngrx work:

  • Actions
  • Reducer: (State, Action) => State'
  • Registration
  • Views

A very simple ngrx app - actions

export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';

export class IncrementCounterAction implements Action{
    readonly type = INCREMENT;

    constructor(public payload: number = 1){
    }
}

actions/counter.ts

A very simple ngrx app - reducer

export function counterReducer(state: number = 0, action: Action) {
  switch (action.type) {
    case INCREMENT:
      return state + 1;

    case DECREMENT:
      return state - 1;

    case RESET:
      return 0;

    default:
      return state;
  }
}

reducers/counter.ts

Testing a reducer is easy!

A very simple ngrx app - registration

@NgModule({
  imports: [
    BrowserModule,
    StoreModule.forRoot(mainReducer),
    // pre-4.x
    StoreModule.provideStore(mainReducer)
  ]
})
export class AppModule {}
interface AppState {
  counter: number;
}

export const mainReducer = { counter: counterReducer };

reducers/index.ts

app.module.ts

A very simple ngrx app - View

@Component({
  selector: 'my-app',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button (click)="increment()">Increment</button>
    <div>Current Count: {{ counter$ | async }}</div>
    <button (click)="decrement()">Decrement</button>

    <button (click)="reset()">Reset Counter</button>
  `
})
class MyAppComponent {
  counter$: Observable<number>;

  constructor(private store: Store<AppState>){
    this.counter$ = store.select((s: AppState) => s.counter);
  }

  increment(){
    this.store.dispatch(new IncrementCounterAction());
  }
  ...
}

A slightly improved app - selector

class MyAppComponent {
	
	constructor(private store: Store<AppState>){
		this.counter$ = store.select(getCounter);
	}
}
export const getCounter = (state: AppState) => state.counter;

reducers/index.ts

Summary: We have

  • written Actions and dispatched them
  • set the initial state and updated it using a reducer.

 

Q: How does this approach scale?

combineReducers

interface PartState {
  foo: A;
  bar: B;
}

export partReducer = combineReducers({ foo: fooReducer, bar: barReducers })

File structure: Classic naming convention

  • reducers/index.ts: combined objects and reducers
  • actions/{storagePart}.ts: action
  • reducers/{storagePart}.ts: reducer and selectors

Alternative file structure

  • reducers/index.ts: combined objects / registration
  • /{feature}/actions/{actionName}.ts: action
  • /{feature}/reducers/{reducerName}.ts: reducer and selectors

Advanced topics

  • effects
  • routing
  • db
  • dev-tools
  • auxiliary libraries

Helsana Clearview

  • Order accident and illness insurance for companies
  • Questionnaire: one large data structure exchanged with server
  • Saved and then store at each page navigate

Demo

Uses for effects

  • Asynchronous actions (workers, rest calls, etc.)
  • Make state from one part of store available for other part

Effects

@Injectable()
export class CollectionEffects {
  @Effect()
  addBookToCollection$: Observable<Action> = this.actions$
    .ofType(collection.ADD_BOOK)
    .map((action: collection.AddBookAction) => action.payload)
    .mergeMap(book =>
      this.db.insert('books', [ book ])
        .map(() => new collection.AddBookSuccessAction(book))
        .catch(() => of(new collection.AddBookFailAction(book)))
    );

  constructor(private actions$: Actions, private db: Database) { }
}

@NgModule({
  imports: [
    BrowserModule,
    StoreModule.provideStore(...),
    EffectsModule.forRoot([CollectionEffects]),
    // pre-4.x.x
    EffectsModule.run(CollectionEffects)
  ]
})
export class AppModule {}

Testing Effects

describe('collection effects', () => {
  let collectionEffects: CollectionEffects;
  let effectsRunner: EffectsRunner;
  beforeEach(() => TestBed.configureTestingModule({
    imports: [
      EffectsTestingModule
    ],
    providers: [
      CollectionEffects,
      ...
    ]
  }));
  beforeEach(() => { /* assign variables from TestBed.get(...) */});

  it('should load collection', () => {
    ...
    effectsRunner.queue(new AddBookAction());
   
    collectionEffects.addBookToCollection$.subscribe(result => {
      expect(result).toEqual(expectedResult);
    });
  });
});

Router & Store

Goal: Synchronize router state and store state

Old implementation:

Store

Router

dispatch(
  go(['foo']))
navigate(['foo'])
UPDATE_LOCATION()

4.x implementation:

Router

Store

dispatch(
  ROUTER_NAVIGATION)
navigate(['foo'])

Action will be cancellable by throwing exception!

DB

  • Saves ngrx state to indexedDb
  • should come with the ngrx 4.0 platform!

Store-Devtools

  • Inspect last few store states

=> DEMO!

3rd party libraries to combine with rxjs

  • ngrx-store-freeze: Enforce immutability for development
  • immutable.js: Immutable types for store
  • reselect: Combine selectors, only update on change
  • ramda.js: Functional library for immutable objects

Problems that ngrx solves

  • A separate layer for app state
  • Component interaction with observables
  • Client-Side Cache
  • Put temporary UI state before saving to server
  • Allow modification of data by multiple actors

When ngrx store does not help much

  • you are new to angular
  • app is very simple
  • data is not shared between views

Questions & Discussion

Useful links:

Thank you:

Emil Effila

Nino Lanfranchi

André Fröhlich

everyone else at ti&m

everyone at Triarc Laboratories

Dibran Isufi

Marcel Tinner

State management in Angular

By gabrielkatz

State management in Angular

  • 1,203