NgRx
NgRx is a framework for managing application state. Without it, our apps would typically:
- manage app state via (stateful) services;
- services would be accessed from various components;
- there would be no control over state mutations.
NgRx is an Angular version of Redux - React’s state management framework. It makes use of RxJS making the state observable. It makes our components less dependent on services, and it makes services simpler and stateless.
Installation
npm i @ngrx/store # Core
npm i @ngrx/effects # Side-effects
(Some) Building Blocks
NgRx exposes three main types of entities:
- store
- reducers
- actions
There are also effects, but they come as part of separate package.
There is one store, but there are many actions and reducers:
- each entity type will have its own reducer (e.g. Products or Users will have their own reducers)
- each entity type will have 1 or more actions (e.g. Product will have AddProduct, UpdateProduct, while Users will have AddUser, RemoveUser, etc.)
- each feature section of our app will have its own
store
directory where the reducer and actions files will be kept
Actions
Actions are the atomic operations that we want to apply on our state. They
represent the interactions of our users with the data kept in the store. Things
such as adding, modifying, deleting data from our state is exposed via actions.
An action can (optionally) carry some data. Additionally, each action has its
own type
. That type is used to inform a reducer about the kind of action that
we want to execute. Action can be viewed as a kind of event.
Since actions are typically small, it’s OK to keep them all in one file (per feature). Here’s an example:
import { props } from "@ngrx/store";
import { createAction } from '@ngrx/store';
// no params
export const logout = createAction('[Auth] Logout');
// with params
export const loginStart = createAction(
'[Auth] Login Start',
props<{ email: string, password: string }>()
)
Alternatively, we can use this syntax:
export const AuthActions = createActionGroup({
source: 'Auth',
events: {
'Logout': emptyProps(),
'Login Start': props<{ email: string, password: string }>()
}
})
The Legacy Syntax works as well.
Actions are dispatched, as we will see a bit later. Actions are a bit
similar to DTO objects. Some actor wants to do something with the store, so it
creates a new Action
instance and dispatches it to the store (to be handled by
a reducer).
Reducers
Reducers handle actions in the context of the store. A single reducer will handle all the actions of a specific entity kind (like a Product). The store in our app is built with the help of reducers!
Reducer is a pure function that is invoked anytime some action is dispatched (and at the bootstrap of our app!). It receives two parameters:
- the current state (or an initial state if we set it up this way)
- the action (with its optional payload)
And, it returns the new state.
export interface State {
user: User;
authError: string;
loading: boolean
}
const initialState : State = {
user: null,
authError: null,
loading: false
};
export const authReducer = createReducer(
initialState,
on(logout, (state) => ({
...state,
user: null
})),
on(authenticateSuccess, (state, { email, userId, token, expirationDate }) => {
const user = new User(
email,
userId,
token,
expirationDate);
return {
...state,
authError: null,
user,
loading: false
};
})
);
It’s important to mention that reducers are very limited in what they actually do. They should first of all take care of the store. They should not interact with the ourside world. Things such as network requests, accessing storage, etc.
- these should be left to effects.
Store Type
We probably are going to have a few kinds of data and a few reducers. It makes sense to define a global type that defines all kinds of data being stored:
// naming convention for imports
import * as fromproductsCatalog from "../products/store/products-catalog.reducer";
import * as fromAuth from "../auth/store/auth.reducer";
export interface AppState {
productsCatalog: fromProductsCatalog.State, // stores stuff related to products catalog
auth: fromAuth.State // stores auth data (like currently logged-in user)
}
With such a type, it’s easier to inject the store into classes. Since the
AppState
type is global within our app, it makes sense to put it in its own
file called app.reducer.ts
, in the /app/store/
directory.
In the same file, we can also put the listing of all the reducers within our app:
export const appReducer: ActionReducerMap<AppState> = {
shoppingList: fromShoppingList.shoppingListReducer,
auth: fromAuth.authReducer
};
It will come useful very quickly.
Using the Store
Register
First of all, our store needs to be imported in some module:
import * as fromApp from "./store/app.reducer"; // again, import convention
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
StoreModule.forRoot(fromApp.appReducer), // appReducer is our reducers map
],
bootstrap: [AppComponent],
})
export class AppModule { }
This is the place where we inform the framework of any reducers that we have defined. In the end, the reducers build up the store in the app.
Inject
Our components or services may inject the Store
in order to access
its data:
import * as fromApp from "../store/app.reducer";
constructor(
private store: Store<fromApp.AppState>
) { }
The Store<>
is a generic type. Thanks to Store Type being
defined as an interface, we can quickly specify our store.
Use
Dispatch
We can dispatch actions like this:
this.store.dispatch(new ProductsCatalogActions.AddProduct(product));
That dispatch will go through NgRx’s internals, which will invoke our reducer.
Based on the action (AddProduct
) it will update the store with the new
product
.
Subscribe
Store’s data is exposed via Observable
s:
products$: Observable<{ products: products[] }>;
...
this.products$ = this.store.select('productsCatalog');
Any dispatch
that “modifies” the store, would bring data via the subscription.
Side Effects
(@ngrx/effects) is an additional package for NgRx that adds Side Effects. Until now we had some actions defined, and reducers that acted on them. Reducers focused on state “modifications”. They should not contain any logic other than that.
Our state actions are often associated with some additional logic that we need to execute - sending some network request, storing some data in local storage, etc. Reducers shouldn’t execute these, because they are pure functions. However, effects can do all those things.
Each feature in our app can have its own store/*.effects.ts
file. Here’s an
example:
@Injectable()
export class AuthEffects {
constructor(
private actions$: Actions,
private httpClient: HttpClient,
private router: Router,
private authService: AuthService) { }
// dispatches another action
authSignUp$ = createEffect(() => this.actions$.pipe(
ofType(AuthActions.signUpStart),
switchMap(({ email, password }) => {
return this.httpClient.post<AuthResponseData>(
"https://identity-provider.com",
{
email,
password
}).pipe(,
map(handleAuthentication),
catchError(handleError)
)
})
))
// does not dispatch any actions
authLogout$ = createEffect(() => this.actions$.pipe(
ofType(AuthActions.logout),
tap(() => {
this.authService.clearLogoutTimer();
localStorage.removeItem('userData');
this.router.navigate(['/auth']);
})
), { dispatch: false })
}
Effects for a given domain is a class with properties representing different effects.
Effects typically return observable of NgRx actions. Example of that could be an effect that:
- Listens for the [Auth] Login Start actions.
- Wehever such action comes in, it sends a login request to some authentication server.
- When the response comes back, it either issues [Auth] Logic Success or [Auth] Login Fail action.
- The reducer sets up the state properly for all 3 kinds of actions mentioned above (it handles them before their effects are executed!).
- Some UI component subscribes to state changes and reacts properly to each state change.
In some cases though, effects do not emit any actions. Instead, an effect could
use a Router to redirect the user somewhere else. To do that,
the createEffect
function needs to be provided with a second argument:
{ dispatch: false }
With effects, our components can be decoupled from the logic of how to do some
external activity (like, how to get some data). All component does is it invokes
some action like FetchBooks
. It also subscribes to the books
slice of the
store to receive the books as soon as they arrive.
Accessing State
Accessing state within an action is possible with the withLatestFrom
RxJs operator. It joins the original observable with data
from some another observable (like some state selector).
Extra Packages
Redux Dev Tools
Redux Dev Tools is a browser extension (or standalone app) that is similar to browser DevTools. It requires the app to be extended with DevTools module, which is not ideal, but allows us to see in detail what happens with the store:
- actions being emited
- shape of the store after each action
- and more…
Router Store
An offical package from NgRx @ngrx/router-store is an addition that emits actions based on Router’s activity. Whenever some navigation happens, etc., a specific action type is emitted with some payload, allowing us to react to it in our actions or reducers.
The Old API
Many projects still use the older API exposed by NgRx. Some of the below code would generate warnings, because the classes/methods are obsolete.
Actions
// action types - there's a convention of prefixing string with the name of feature
// in square brackets to avoid potential conflicts with other features
export const ADD_PRODUCT = '[Products Catalog] Add Product';
export const ADD_PRODUCTS = '[Products Catalog] Add Products';
// actions
export class AddProduct implements Action {
readonly type = ADD_INGREDIENT; // comes with the Action interface
// optional data
constructor(public payload: Product) {}
}
export class AddProducts implements Action {
readonly type = ADD_INGREDIENTS;
constructor(public payload: Product[]) {}
}
// such a union type makes the reducer's code more succinct
export type ProductsActions = AddIngredient | AddIngredients;
Reducer
// a typical import grouping all actions under one object
import * as ProductsCatalogActions from "./products.actions";
// a type of ProductsCatalog state.
export interface State {
products: Product[];
selectedProduct?: Product
}
// optional initial state
const initialState: State = {
products: [
new Product(1, 'Book'),
new Ingredient(2, 'Steam Deck'),
],
selectedProduct: null
}
// the actual reducer is a function
export function productsCatalogReducer(
state: State = initialState, // reducer will be called by Angular on init with null state, so initialStae will jump in
action: ProductsCatalogActions.ProductsActions // our action union type
) {
// handles each action
switch(action.type) {
case ProductsActions.ADD_PRODUCT:
return {
...state, // a typicl pattern of copying the rest of the previous state
products: [...state.products, action.payload ] // products field of the state gets replaced
}
case ProductsActions.ADD_PRODUCTS:
return {
...state,
products: [...state.products, ...action.payload]
}
default:
// the initial call (invoked by Angular) will not have any action.type, we return the initialState here
return state;
}
}
Effects
@Injectable()
export class RecipesEffects {
constructor(
private actions$: Actions,
private httpClient: HttpClient,
private store: Store<fromApp.AppState>) {
}
@Effect()
fetchRecipes = this.actions$.pipe(
ofType(RecipesActions.FETCH_RECIPES),
switchMap(() => this.httpClient.get<Recipe[]>("https://my-api.com")),
map(recipes => {
return recipes.map(recipe => {
return {
...recipe,
ingredients: recipe.ingredients ? recipe.ingredients : []
};
})
}),
map(recipes => new SetRecipes(recipes)));
@Effect()
storeRecipes = this.actions$.pipe(
ofType(RecipesActions.STORE_RECIPES),
withLatestFrom(this.store.select('recipes')),
switchMap(([_, { recipes }]) => {
return this.httpClient.put("https://recipes.api.com",
recipes)
})
)
}