Adds an abstraction layer / facade between Angular components and the @ngrx store
Clone or download
Latest commit 28ec4b0 Aug 15, 2018

README.md

@ngxp/store-service

Adds an abstraction layer between Angular components and the @ngrx store and effects. This decouples the components from the store, selectors, actions and effects and makes it easier to test components.

Table of contents

Installation

Get the latest version from NPM

The current version requires Angular 6.1

npm install @ngxp/store-service

If you use Angular 6.0 please use version 3.0.0

npm install @ngxp/store-service@3.0.0

Comparison

Dependency diagram comparison

Before

Component

import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { Book } from 'src/app/shared/books/book.model';
// Tight coupling to ngrx, state model, selectors and actions
import { Store } from '@ngrx/store'; 
import { Actions, ofType } from '@ngrx/effects'; 
import { AppState } from 'src/app/store/appstate.model';
import { getAllBooks } from 'src/app/store/books/books.selectors'; 
import { ActionTypes, AddBookAction } from 'src/app/store/books/books.actions'; 
 
@Component({
    selector: 'nss-book-list',
    templateUrl: './book-list.component.html',
    styleUrls: ['./book-list.component.scss']
})
export class BookListComponent {

    books$: Observable<Book[]>;
    booksLoaded: boolean = false;

    constructor(
        private store: Store<AppState>
        private actions: Actions
    ) {
        this.books$ = this.store.select(getAllBooks());
        this.actions
            .pipe(
                ofType(ActionTypes.BooksLoaded),
                map(() => this.loaded = true)
            )
            .suscribe();
    }

    addBook(book: Book) {
        this.store.dispatch(new AddBookAction(book));
    }
}

After

Component

import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { Book } from 'src/app/shared/books/book.model';
import { BookStoreService } from 'src/app/shared/books/book-store.service'; 
// Reduced to just one dependency. Loose coupling

@Component({
    selector: 'nss-book-list',
    templateUrl: './book-list.component.html',
    styleUrls: ['./book-list.component.scss']
})
export class BookListComponent {

    books$: Observable<Book[]>;
    booksLoaded: boolean = false;

    constructor(
        private bookStore: BookStoreService // <- StoreService
    ) {
        this.books$ = this.bookStore.getAllBooks(); // <- Selector
        this.bookStore.booksLoaded$ // <-- Observer / Action stream of type
            .pipe(
                map(() => this.loaded = true)
            )
            .subscribe();
    }

    addBook(book: Book) {
        this.bookStore.addBook(book); // <- Action
    }
}

BookStoreService

import { Injectable } from '@angular/core';
import { Select, StoreService, Dispatch } from '@ngxp/store-service';
import { Observable } from 'rxjs';
import { Book } from 'src/app/shared/books/book.model';
import { getBooks } from 'src/app/store/books/books.selectors';
import { State } from 'src/app/store/store.model';
import { AddBookAction } from 'src/app/store/books/books.actions';

@Injectable()
export class BookStoreService extends StoreService<State> {

    @Select(getBooks) // <- Selector
    getAllBooks: () => Observable<Book[]>;

    @Dispatch(AddBookAction) // <- Action
    addBook: (book: Book) => void;

    @Observe([Actiontypes.BooksLoaded])
    booksLoaded$: Observable<Book[]>; // <- Observer / Action stream
}

Documentation

StoreService

The BookStoreService Injectable class has to extend the StoreService<State> class where State is your ngrx state model.

import { StoreService } from '@ngxp/store-service';
import { AppState } from 'app/store/state.model';

@Injectable()
export class BookStoreService extends StoreService<AppState> {
    ...
}

Selectors

To use selectors you add the @Select(...) decorator inside the StoreService. Provide the selector function inside the @Select(...) annotation:

// Define the selector function
export function selectAllBooks() {
    return state => state.books;
}

...

// Use the selector function inside the @Select(...) annotation
@Select(selectAllBooks)
allBooks: () => Observable<Book[]>;

The selector needs to be a function.

Be sure to use correct typing for the property inside the StoreService. If a parameter is required inside the selector function it has to be required in the property typing.

export function selectBookById(id: number) {
                              ^^^^^^^^^^^^
    return state => state.books[id];
}

...

@Select(selectBookById)
getBook: (id: number) => Observable<Book>;
         ^^^^^^^^^^^^
// The typing of the selector function and the property have to match!

Actions

To dispatch actions add a property with the @Dispatch(...) annotation.

// Defined the Action as a class
export class LoadBooksAction implements Action {
    public type = '[Books] Load books';
}

...
// Use the Action class inside the @Action(...) annotation
@Dispatch(LoadBooksAction)
loadBooks: () => void;

If the Action class expects parameters, the typings on the property inside the StoreService have to match the class constructor. Actions are instantiated using the new keyword.

export class AddBookAction implements Action {
    public type = '[Books] Add book';
    constructor(
        public payload: Book
               ^^^^^^^^^^^^^
    ) {}
}

...
@Dispatch(AddBookAction)
addBook: (book: Book) => void;
         ^^^^^^^^^^^^
// The typing of the action constructor and the property have to match!

Observers

Observers are a way to listen for specific action types on the Actions stream from @ngrx/effects.

@Observe([Actiontypes.BooksLoaded])
booksLoaded$: Observable<Book[]>;

It will automatically map the action to it's payload property but this can be changed. The @Observe(...) decorator wraps the following functionality:

this.actions.pipe(
    ofType(ActionTypes.BooksLoaded),
    map(action => action.payload)
)

Multiple types

You can provide multiple types, just like in the ofType(...) pipe.

@Observe([Actiontypes.BooksLoaded, Actiontypes.BookLoadFailed])
booksLoaded$: Observable<Book[] | string>;

Objects with type property

Objects with a type property are also valid.

const action = { type: 'booksLoaded' };
...
@Observe([action])
booksLoaded$: Observable<Book[]>;

Custom toPayload mapper

The @Observe(...) decorator has an additional parameter to provide a custom toPayload mapping function. Initially this will be:

action => action.payload

To use a custom mapper, provide it as second argument in the @Observe(...) annotation.

export const toData = action => action.data;

...
@Observe([ActionTypes.DataLoaded], toData)
dataLoaded$: Observable<Data>;

Prerequisites

Selectors are functions

// This will not work
const selector = state => state.property;
// This works
function selector() {
    return state => state.property;
}

If the selector is not a function, the typing of the StoreService Class won't work: () => Observable<any>

Actions are classes

// This will not work
export function LoadAction(payload: any) {
    return {
        type: 'Load action',
        payload
    };
}
// This works
export class LoadAction implements Action {
    public type: 'Load action';
    constructor(
        public payload: any
    ) { }
}

This is mandatory because the actions are instantiated using the new keyword.

Testing

Testing your components and the StoreService is made easy. The @ngxp/store-service/testing package provides helpful test-helpers to reduce testing friction.

Testing Selectors

To test selectors you provide the StoreService using the provideStoreServiceMock method in the testing module of your component. Then cast the store service instance using the StoreServiceMock<T> class to get the correct typings.

import { provideStoreServiceMock, StoreServiceMock } from '@ngxp/store-service/testing';
...
let bookStoreService: StoreServiceMock<BookStoreService>;
...
TestBed.configureTestingModule({
    imports: [AppModule],
    providers: [
        provideStoreServiceMock(BookStoreService)
    ]
})
...
bookStoreService = TestBed.get(BookStoreService);

The StoreServiceMock class replaces all selector functions on the store service class with a BehaviorSubject. So now you can do the following to emit new values to the observables:

bookStoreService.getAllBooks().next(newBooks);

The BehaviorSubject is initialized with the value being undefined. If you want a custom initial value, the provideStoreServiceMock method offers an optional parameter. This is an object of key value pairs where the key is the name of the selector function, e.g. getAllBooks.

import { provideStoreServiceMock, StoreServiceMock } from '@ngxp/store-service/testing';
...
let bookStoreService: StoreServiceMock<BookStoreService>;
...
TestBed.configureTestingModule({
    imports: [AppModule],
    providers: [
        provideStoreServiceMock(BookStoreService, {
            getAllBooks: []
        })
    ]
})
...
bookStoreService = TestBed.get(BookStoreService);

The BehaviorSubject for getAllBooks is now initialized with an empty array instead of undefined.

Testing Actions

To test if a component dispatches actions, you import the NgrxStoreServiceTestingModule inside the testing module.

To get the injected Store instance use the MockStore class for proper typings.

import { NgrxStoreServiceTestingModule, MockStore } from '@ngxp/store-service/testing';
...
let mockStore: MockStore;
...
TestBed.configureTestingModule({
    imports: [
        NgrxStoreServiceTestingModule
    ]
})
...
mockStore = TestBed.get(Store);

Optionally use the withState(...) function on the NgrxStoreServiceTestingModule to provide an object that should be used as the state.

import { NgrxStoreServiceTestingModule} from '@ngxp/store-service/testing';
...
const state = {
    books: []
}
...
TestBed.configureTestingModule({
    imports: [
        NgrxStoreServiceTestingModule.withState(state)
    ]
})

The MockStore class has a dispatchedActions property which is an array of all dispatched actions. The last dispatched action is appended at the end.

const lastDispatchedAction = mockStore.dispatchedActions[mockStore.dispatchedActions.length - 1];

// Or with lodash

const lastDispatchedAction = last(mockStore.dispatchedActions);

Testing Observers

There are two different ways to test Observers depending on what you want to test. You can either use the StoreServiceMock or the MockActions. The StoreServiceMock replaces all Observers inside the StoreService with a BehaviorSubject. This should be used for component tests. The MockActions provide a custom Actions subject you can emit new actions to. This should be used to test the StoreService itself.

StoreServiceMock

To test observers inside components you provide the StoreService using the provideStoreServiceMock method in the testing module of your component. Then cast the store service instance using the StoreServiceMock<T> class to get the correct typings.

import { provideStoreServiceMock, StoreServiceMock } from '@ngxp/store-service/testing';
...
let bookStoreService: StoreServiceMock<BookStoreService>;
...
TestBed.configureTestingModule({
    imports: [AppModule],
    providers: [
        provideStoreServiceMock(BookStoreService)
    ]
})
...
bookStoreService = TestBed.get(BookStoreService);

The StoreServiceMock class replaces all observer properties on the store service class with a BehaviorSubject. So now you can do the following to emit new values to the subscribers:

bookStoreService.booksLoaded$.next(true);

The BehaviorSubject is initialized with the value being undefined. If you want a custom initial value, the provideStoreServiceMock method offers an optional parameter. This is an object of key value pairs where the key is the name of the observer property, e.g. booksLoaded$.

import { provideStoreServiceMock, StoreServiceMock } from '@ngxp/store-service/testing';
...
let bookStoreService: StoreServiceMock<BookStoreService>;
...
TestBed.configureTestingModule({
    imports: [AppModule],
    providers: [
        provideStoreServiceMock(BookStoreService, {
            booksLoaded$: false
        })
    ]
})
...
bookStoreService = TestBed.get(BookStoreService);

The BehaviorSubject for booksLoaded$ is now initialized with false instead of undefined.

MockActions

To test the observers / actions stream, you import the NgrxStoreServiceTestingModule inside the testing module.

Get the MockActions instance from the TestBed

import { NgrxStoreServiceTestingModule, MockActions } from '@ngxp/store-service/testing';
...
let mockActions: MockActions;
...
TestBed.configureTestingModule({
    imports: [
        NgrxStoreServiceTestingModule
    ]
})
...
mockActions = TestBed.get(MockActions);

The MockActions class provides a next(...) function which will emit a new value to the Actions stream from @ngrx/effects. This way you can emit new actions to the stream.

Here is an example on how to test this using the MockActions class.

import { NgrxStoreServiceTestingModule, MockActions } from '@ngxp/store-service/testing';
...
let mockActions: MockActions;
...
TestBed.configureTestingModule({
    imports: [
        NgrxStoreServiceTestingModule
    ]
})
...
mockActions = TestBed.get(MockActions);
...
it('test', () => {
    const expectedValue = [ { author: 'Author', title: 'Title', year: 2018 } ];
    storeService.booksLoaded$.subscribe(
        books => {
            expect(books).toBe(expectedValue);
        }
    );
    const action = new BooksLoadedAction(expectedValue);
    mockActions.next(action)
})

Examples

For detailed examples of all this have a look at the Angular Project in the src/app folder.

Example Store Service

Have a look at the BookStoreService

Example Tests

For examples on Component Tests please have look at the test for the BookListComponent and the NewBookComponent

Testing the StoreService is also very easy. For an example have a look at the BookStoreService