Quick Introduction
The redux pattern is a state management solution, which makes it clear that it manages the state in the application. NgRx store, on the other hand, is an implementation of this pattern for Angular.
The main theme of the redux pattern is it being event-driven; i.e. changes in the state are asynchronous making the state itself eventually consistent (however, this might not be significant).
NgRx State Management Overview
In their documentation, NgRx offers the following lifecycle map of how the state management takes place. I added a few annotations (in green) to use global software development terminology that is usually used in the event-driven architecture
Terminology
Let's delve into some definitions:
Store: where the state is persisted. You can think of it as a local database.
Action: messages (can be commands or events) that are dispatched to result in a change of state. Note here that NgRx does not offer any differentiation between actions that act as commands and those that act as events, however, I mentioned this functional differentiation to help understand the role of this entity.
Reducer: a function that acts as a handler for the dispatched actions. A reducer acts directly on the state and is expected to be pure (does not have any side effects, and always results in the same output when the same input is injected into it)
Selector: exposed query methods that act as an API to read the data from the state.
…Then what about effects?
Effects, like reducers, are action handlers, however, are not pure functions. They are used when an interaction needs to be done with an external server, either to fetch data or do some action.
Okay… but why?
NgRx claims that the above architecture results in many benefits for the programmers. However, I am not going to delve into all of them, as I personally don't see it the same way they do. Nevertheless, two main gains can be achieved from this tool:
- Storing the state locally, which plays the role of a cache to avoid re-fetching the same data over and over
- Reacting to change in the state in a managed way, using Angular's OnPush change detection strategy
When the state in the store changes, any logic in any component that is subscribed to a selector that queries this state will be re-executed. This helps update different parts of the screen easily, without the need to pass data as input to different layers of components.
NgRx Store in Action
Time to get our hands dirty with some code 🤠
This is not a detailed tutorial on NgRx, so it's assumed that you have it set up on your project. If not, check the official documentation.
Let's assume we're building a website for a library, and we need to manage the state of books we have.
We will start with the command to fetch all books from the server. As we mentioned earlier, commands are represented in the NgRx store in the form of actions. So, we will add our actions file:
// books.actions.ts
import { createAction } from "@ngrx/store";
export const fetchBooksCommand = createAction("Fetch books")
To handle this action, we typically need a reducer or an effect. Since we will need an external server to fetch the data from, we will go with an effect for this one. Note that effects require a special dependency.
// books.effetcs.ts
import { Injectable } from "@angular/core";
import { Actions, createEffect, ofType } from "@ngrx/effects";
import { BooksService } from "../books.service";
import { booksFetchedSuccessfullyEvent, fetchBooksCommand } from "./books.actions";
import { exhaustMap, map } from "rxjs";
@Injectable()
export class BooksEffects {
constructor(private actions$: Actions,
private bookService: BooksService) {
}
fetchBooks$ = createEffect(() => this.actions$.pipe(
ofType(fetchBooksCommand),
exhaustMap(() => this.bookService.getBooks()
.pipe(
map(response => booksFetchedSuccessfullyEvent({ books: response }))
)
)
))
}
// books.actions.ts
import { createAction, props } from "@ngrx/store";
import { Book } from "../book";
export const booksFetchedSuccessfullyEvent = createAction("Books fetched successfully", props<{ books: Book[] }>())
// app.module.ts
@NgModule({
declarations: [
... ],
imports: [
...
EffectsModule.forRoot([ BooksEffects ])
],
providers: [ ... ],
bootstrap: [ AppComponent ],
})
export class AppModule {
}
// or if you're using a standalone app component:
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { BooksEffects } from "./books/state/books.effects";
export const appConfig: ApplicationConfig = {
providers: [provideStore(), provideEffects([ BooksEffects ])]
};
From the above example, we can see that the effect listens on an observable action. This can be compared to listening to a queue of messages in the backend lingo. The effect then specifies the type of action it is responsible for reacting to. This is similar to filtering the messages received in the queue by type. Once the received action matches the type the effect is waiting for, it handles it by fetching the needed data from the server. It then converts this action to another action for it to be handled by a reducer.
I think, here is where the over-complication of logic begins, but with the needed analysis, we were able to figure out the flow of logic.
Now comes the part of projecting these changes onto the state, and for that we need a reducer:
// books.reducer.ts
import { createReducer, on } from "@ngrx/store";
import { booksFetchedSuccessfullyEvent } from "./books.actions";
import { Book } from "../book";
const initialState: Book[] = [];
export const booksReducer = createReducer(
initialState,
on(booksFetchedSuccessfullyEvent, (oldState, { books}) => books)
)
// app.module.ts
@NgModule({
declarations: [
... ],
imports: [
...
StoreModule.forRoot({ books: booksReducer })
],
providers: [ ... ],
bootstrap: [ AppComponent ],
})
export class AppModule {
}
// or if you're using a standalone app component:
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { booksReducer } from "./books/state/books.reducer";
export const appConfig: ApplicationConfig = {
providers: [provideStore({ books: booksReducer })]
};
Notice above how a reducer must define an initial state, and then list the action handlers. Each of the action handlers first specifies the target action it is responsible for, then provides a lambda of how this action affects the state. The provided lambda takes the old state and any data provided by the action as an input and returns the new state, hence you can see that reducers in NgRx are pure functions.
One might ask, can the same action be handled by a reducer and an effect at the same time? This might be tricky since we saw that the effect converts the action to another action once it finishes handling it. However, the official documentation of NgRx store states that reducers take precedence over effects in handling the actions first, hence we can have an effect and a reducer handling the same action:
Note: All Actions that are dispatched within an application state are always first processed by the Reducers before being handled by the Effects of the application state.
Now, for the last part, in order to be able to use the data from the store, we need selectors:
// books.selectors.ts
import { createSelector } from "@ngrx/store";
import { AppState } from "../../app.state";
const selectBooksState = (state: AppState) => state.books
export const selectBooks = createSelector(selectBooksState, (books) => books)
// app.state.ts
import { Book } from "./books/book";
export interface AppState {
books: Book[]
}
Note: for each defined selector, we need to provide it with a “feature selector” (tackled in a later section in this article), and with a projection mechanism of the data to define what part of the data this selector is returning.
Now we can use all this as follows:
// books.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable } from "rxjs";
import { Book } from "./book";
import { AppState } from "../app.state";
import { Store } from "@ngrx/store";
import { selectBooks } from "./state/books.selectors";
import { fetchBooksCommand } from "./state/books.actions";
@Component({
selector: 'app-books',
templateUrl: './books.component.html',
styleUrl: './books.component.css'
})
export class BooksComponent implements OnInit {
books$: Observable<Book[]>
constructor(private store: Store<AppState>) {
this.books$ = this.store.select(selectBooks);
}
ngOnInit(): void {
this.store.dispatch(fetchBooksCommand())
}
}
What about the OnPush updates?
Given the above approach, if we have anywhere in the code a dispatched action that results in updating the state of books in the store, the observable books$
will automatically be updated, and any logic subscribed to it will be re-executed.
Feature Selectors?
NgRx store allows you to have decentralized management of the overall state of the application. For the above example, if we were interested in having the state of “papers” persisted in the store as well, we would repeat the above work, with a small change on the configuration of the app module:
// app.module.ts
@NgModule({
declarations: [
... ],
imports: [
...
StoreModule.forRoot({ books: booksReducer, papers: paperReducer })
],
providers: [ ... ],
bootstrap: [ AppComponent ],
})
export class AppModule {
}
// or if you're using a standalone app component:
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { booksReducer } from "./books/state/books.reducer";
export const appConfig: ApplicationConfig = {
providers: [provideStore({ books: booksReducer, papers: paperReducer })]
};
In this example, “books” and “papers” are called “Features” in NgRx store. So, when we want to define the selectors of a feature, we need to let it know how to select the feature state from the global app state:
// papers.selectors.ts
const selectPaperState = (state: AppState) => state.papers
export const selectPaperCount = createSelector(selectPaperState, (papers) => papers.count)
Final word
NgRx store does indeed provide us with some cool functionalities, but I think the cost of code-verbosity is a bit high and can be a significant factor when choosing whether or not to use this tool. Let me know about your thoughts in the comment section below.
You can check the full code in this GitHub repository.
Cheers,