Skip to content
This repository was archived by the owner on Dec 5, 2024. It is now read-only.
Gregor Woiwode edited this page Oct 28, 2022 · 2 revisions

1. Setup

5min

  • Install @ngrx/store with ng add @ngrx/store.

  • Install @ngrx/store-devtools with ng add @ngrx/store-devtools.

  • Open app.module.ts and have a look on the modules automatically set up for you.

  • Make sure having Redux Devtools installed in your Browser (Chrome, Firefox).

Bonus

  • If you like to have more consistency in your NgRx code base you might want to check out @ngrx/eslint-plugin. It represents a list of the recommended code style used by the NgRx Team
  • Install @ngrx/eslint-plugin with ng add @ngrx/eslint-plugin
  • You can read about each rule in the docs: https://ngrx.io/guide/eslint-plugin

2. Dispatch an Action

10min

  • Create a new directory book/store/.
  • Create the file store/book-collection.actions.ts.
  • Create the action createBookStart that requires the property { book: Book}.
  • Make sure that the action is exported.
  • Open book-new.component.ts
  • Inject the Store
  • Dispatch createBookStart in the method create
  • Open Redux DevTools in your browser.
  • Make sure that the dispatched action appears in the action log.
  • Take some time to have a look what features the DevTools have.
  • This task also has a bonus part.

Hint

// book-new.component.ts
import { Store } from '@ngrx/store';

this.store.dispatch(createBookStart());

Bonus

Replace relative import paths with path-alias

  • Create a barrel file (index.ts) inside store/book
  • Make the barrel file export book-collection.actions.ts
  • Open the root-tsconfig.json
  • Add the path alias @store/book that points to ./src/app/book/store/index
  • Update the import-path of createBookStart to use @store/book instead of the relative path.
"paths": {
  "@store/book": ["./src/app/book/store/index"]
}
import { createBookStart } from '@store/book';

Solution

3. Reducer

10min

Introduce Reducer

  • Create a new file book/store/book-collection.slice.ts.
  • Create and export the interface BookCollectionSlice specifying the shape of your state slice.
  • Create a new file book/store/book-collection.reducer.ts.
  • Create a reducer function using createReducer.
  • Specify an initial state.
  • Handle the action createBookStart.
  • Add the book provided by the action to a list.
  • Keep in mind that list operations have to be immutable.
  • Register the reducer in book.module.ts by adding StoreModule.forFeature.
  • Specify a name for the feature containing the book state.
  • Export the name in its own file book/store/book.feature.ts because we need to reuse the feature name later on.

Manual Test

  • Return to your application.
  • Create a new book in the form
  • Submit the form
  • Check the Redux DevTools
  • Expect to see the dispatched action createBookStart to have changed the state by adding a book to the state.

Hint

// book.feature.ts
export const bookFeatureName = 'book';

// book-collection.slice.ts
export interface BookCollectionSlice {
  entities: ReadonlyArray<Book>;
}

// book-collection.reducer.ts
import { createReducer } from '@ngrx/store';

export  const bookCollectionReducer = createReducer(/* ... */)

// book.module.ts
StoreModule.forFeature(bookFeatureName, { bookCollection: bookCollectionReducer })

Solution

4. Store Select

  • Open book-list.component.ts
  • Inject the Store
  • Use the Store selecting the books instead of using the BookApiService

Now, we are disconnected from the API. Later on we will load the books via HTTP again.

  • Create a new book.
  • After navigating back to the book list you should see the created book selected from the store.

Hint

5min

Current shape of the Store

{
  book: {                    // feature name
    bookCollection: {        // slice
      entities: []           // data
    }
  }
}

Select data

this.store.select(state => state.<feature name>.<slice>.<data>)

Solution

5. Selector

5min

  • Open the file book/store/book.feature.ts

  • Create a feature selector using the constant specifying the feature name.

  • Create the file 'book/store/book-collection.selectors.ts'.

  • Import the feature selector from book.feature.ts

  • Create a selector providing the books of the store slice.

  • Open the file book/book-list.component.ts

  • Use the selector you have created instead of the inline projection function

  • Create a new book.

  • After navigating back to the book list, you should see the created book selected from the store.

Hint

import { createFeatureSelector, createSelector } from '@ngrx/store';

createFeatureSelector<{ bookCollection: BookCollectionSlice }>(bookFeatureName);

createSelector();

Solution

6. Selectors with Parameters

10min

  • Open book/store/book-collection.selectors.ts.

  • Add the selector bookByIsbn that accepts an ISBN as parameter.

  • Make the selector returning one book that has the given ISBN.

  • Open book-details.component.ts.

  • Update initialization of book$ by selecting the book from the store instead of using BookApiService.

  • Use the selector bookByIsbn

This task has a bonus part

Hint

// book-collection.selectors.ts
export const bookByIsbn = (isbn: string) =>
  createSelector(<reuse the selector you have written to select the books>, books => books.find(book => book.isbn === isbn));

// fragment from book-details.component.ts
switchMap(params => this.store.select(bookByIsbn(params.isbn)))

Bonus

  • Use the selector bookByIsbn in book-edit.component.ts, too.

Solution

7. ActionReducerMap

5min

  • Open book/store/book.feature.ts.

  • Add the intrerface BookState representing the shape of the book-feature.

  • Assign ActionReducerMap<BookState> to an exported const named bookReducers, collecting all reducers of the book-feature.

  • Update the feature-selector to use the interface BookState instead of the inline typing.

  • Open book.module.ts.

  • Update the StoreModule.forFeature-declaration to use bookReducer, replacing the inline configuration for reducers.

Solution

8. Effects I

10min

  • Install @ngrx/effects by executing ng add @ngrx/effects.

  • Check if the installation was successful by having a look at app.module.ts.

    • You should see EffectsModule.forRoot([])
  • Open book/store/book-collection.actions.ts

  • Create the action loadBookStart, being used to send a GET-Request to the API.

  • Create the action loadBookComplete, being used to react to the response of the API.

  • Create the file book/store/book-collection.effects.ts

  • Implement the effect load that uses BookApiService to load all books.

  • Return the action loadBookComplete, when the books have been loaded successfully.

  • Register BookCollectionEffects in book.module.ts by adding EffectsModule.forFeature([BookCollectionEffects]).

  • Open book/store/book-collection.reducer.ts.

  • Update the reducer function handling the action loadBookComplete.

    • It should save the books coming from the API to the store.
  • Open book.component.ts.

  • Dispatch the action loadBookStart triggering the GET-Request loading all books.

  • Visit your app again.

  • Check if the books are loaded instantly when you visit the book list.

Hint

// actions
export const loadBooksStart = createAction('[Book] Load Books Started');
export const loadBooksComplete = createAction('[Book] Load Books Completed', props<{ books: Book[] }>());

Solution

9. Effects II

15min

  • Implement the feature "Create Book" using the NgRx infrastructure.

Hint

export const createBookComplete = createAction('[Book] Create Book Completed', props<{ book: Book }>());

Solution

10. Effects III

10min

  • Implement the feature "Update Book" using the NgRx infrastructure.
  • Implement the feature "Delete Book" using the NgRx infrastructure.

Hint

export const deleteBookStart = createAction('[Book] Delete Book Started');
export const deleteBookComplete = createAction('[Book] Delete Book Completed', props<{ bookIsbn: string }>());

export const updateBookStart = createAction('[Book] Update Book Started', props<{ patch: Book }>());
export const updateBookComplete = createAction('[Book] Update Book Completed', props<{ update: Book }>());

Solution

11. Entity

10min

  • Install NgRx Entity by executing ng add @ngrx/entity.
  • Update the interface BookCollectionSlice to extend EntityState<Book>.
  • Update the reducer bookCollectionReducer using the EntityAdapter-Methods getInitialState, addOne, updateOne, removeOne, setAll.
  • Use the selector selectAll of the entityAdapter for the selector bookCollection

Hint

// book-collection.slice.ts
export type BookCollectionSlice = EntityState<Book>;

// book-collection.reducer.ts
const adapter = createEntityAdapter<Book>({ selectId: model => model.isbn });

Solution

12. Router Store

10min

  • Install the RouterStore by executing ng add @ngrx/router-store

  • Register the RouterStore's reducer in app.module.ts: StoreModule.forRoot({ router: routerReducer }, { /* ... */ })

  • Create the file app/store/router/router.selectors.ts

  • Reexport all RouterStore's selctors (see: NgRx Docs | Selectors)

  • Open book/store/book-collection.selectors.ts

  • Update bookByIsbn to use the RouterStore's selector selectRouteParam('isbn')

    • Therefore you need to compose two selectors: bookCollection & selectRouteParam
    • This means bookByIsbn no longer takes a parameter because we now get the route parameter from the Store.
  • Update all usages of bookByIsbn

  • Remove the ActivatedRoute-Service from the respective components.

Notice that each component that is fully migrated has only one dependency, the Store.

Hint

You can updating the effects as well retrieving the params from the store as well.

 // ...
 concatLatestFrom(() => this.store.select(selectRouteParam('isbn'))),
 exhaustMap(([, bookIsbn]) => this.bookApi.delete({ bookIsbn }),

Bonus

Replace relative import paths with path-alias

  • Create a barrel file (index.ts) inside app/store/router.
  • Make the barrel file export router-store-related files.
  • Open the root-tsconfig.json.
  • Add the path alias @store/router that points to ./src/app/store/router/index.
  • Update relevant import-paths.
"paths": {
  "@store/router": ["./src/app/store/router/index"]
}

Solution

13. Mock Selector

10min

  • Open book-list.component.spec.ts.
  • Write a test ensuring that books are rendered in the template
  • Therefore, mock the selector to return a collection of fake books.

Ressource: NgRx Testing

Hint

import { MockStore, provideMockStore } from '@ngrx/store/testing';

providers: [provideMockStore()],
 
store = TestBed.inject(MockStore);
store.overrideSelector(bookCollection as any, [new BookNa()]);

Solution

14. Test | Action - Effect - Reducer - Selector

  • Create book/store/book-collection.effects.spec.ts
  • Write a test verifying that dispatching createBookStart ends up in caching the created book in the Store.
  • The flow of the test could be
    • Set up a mock for the BookApiService
    • Configure the TestBed to import EffectsModule.forRoot, StoreModule.forRoot, StoreModule.forFeature.
    • Dispatch the action createBookStart with a mocked book
    • Use the selector bookCollection to query the books of the store
    • Assert that the dispatched book is part of the collection

Hint

15min

// Service Mock
bookApiMock = jasmine.createSpyObj<BookApiService>(['create']);
bookApiMock.create.and.returnValue(of(book));

// TestBed
TestBed.configureTestingModule({
  imports: [
    RouterTestingModule,
    EffectsModule.forRoot([BookCollectionEffects]),
    StoreModule.forRoot({}),
    StoreModule.forFeature(bookFeatureName, bookReducers)
  ],
  providers: [{ provide: BookApiService, useFactory: () => bookApiMock }]
});

// Act
store.dispatch(createBookStart({ book }));

// Assertion
store.select(bookCollection).subscribe(books => {
  expect(books).toContain(book);
  done();
});

Solution

15 Test | Reducer

10min

  • Create the file book/store/book-collection.reducer.spec.ts
  • Write a test ensuring that action bookCreateComplete adds a book to the store.

This task contains a bonus part.

Hint

const initialState: EntityState<Book> = { entities: {}, ids: [] };
const action = createBookComplete({ book });
const state = bookCollectionReducer(initialState, action);

Bonus

  • Write tests for every action handled by bookCollectionReducer.

Solution

16. Workspace

  • Migrate to NX
  • If you are using path aliases, you need to update them manually
  • Create an App-Library for books

Hint

 npx @angular/cli add @nrwl/angular
"paths": {
-      "@store/book": ["./src/app/book/store/index"],
-      "@store/router": ["./src/app/store/router/index"]
+      "@store/book": ["./apps/workshop/src/app/book/store/index"],
+      "@store/router": ["./apps/workshop/src/app/store/router/index"]
    },