Partager

8 octobre 2020

Utiliser NgRx avec Angular - Par Matthieu Hahn (Article en anglais)

Dans cet article, Matthieu Hahn nous partage ce qu'il a appris en travaillant sur un projet Angular utilisant NgRx alors qu'il n'était qu'aux prémisses de son apprentissage avec ces technologies.

What is NgRx 🤷🏼‍♂️ ?

NgRx is a framework for building reactive applications in Angular. https://ngrx.io/docs

A reactive application is an application that depends on data streams and propagation of change.

Eg.: You want to build a component which needs to fetch a list of products to display it. If a product is added later to this list by some other component, you won't need to add anymore logic to the first component in order to manage the change in the state.

So, should I use it then ?

Well, as most things in tech, there are cases where it is not really suited, and some where it's the best bet.

I wouldn't recommend using NgRx if the app you are building doesn't have many user interactions, isn't too complex. In this case you probably won't need it.

In a simple application I would clearly recommended to store the states in the services and call the services from the components.

However, if a state is accessed by multiple components, is updated with external data, needs to be used when re-entering a route or if the state gets modified by the actions of other sources then it is a hell of a good deal. It also brings quite a bit of structure to the project.

In other terms, it is important to understand that using NgRx will add quite a bit of complexity to the project's structure, so the choice has to be thought through. Also, it is not that easy to understand when you are not used to managing states this way. I found it a bit disconcerting at first, but after a few days, I really got the hang of it.

Ok, then how does it work ?

Here's a quick diagram I got from GitHub that I found pretty clear (once it was explained to me 😂). I recommend you go back to this diagram at each section of this article, it should become clearer.

Actions

Actions are unique events which can happen in you app. They have a type and can eventually carry properties to add some context.

Eg: I need my component to fetch products as earlier. Instead of directly calling the products service and wait for the result, the component will dispatch an action

Without NgRx:

products.component.ts

constructor(private productService: ProductService) {
  this.productService.getAll()
    .subscribe((products) => {
       this.products = products;
    });
}

With NgRx: products.action.ts

Enumerate the actions, it's cleaner when you need them elsewhere.

export enum ProductActionTypes {
  FETCH_PRODUCTS = '[Products] Fetch products',
}

Implement the action (Add a type, and eventually some context)

export class FetchProducts implements Action {
  readonly type = ProductActionTypes.FETCH_PRODUCTS;
}

Export the actions type, it'll be useful later on

export type ProductsActions =
  | FetchProducts

products.component.ts

constructor(private readonly store: Store) {
  this.store.dispatch(new FetchProducts());
}

Ok then, we have started to isolate the component from the service by dispatching an action, what happens next ? Well, actions are processed by reducers and effects.

Reducers

Reducers manage the state transitions by listening to the actions which are dispatched. If you think about the example you'll see there are in fact 3 different states:

  • State 1: The products are being fetched
  • State 2: The products have been fetched with success
  • State 3: The products fetching failed

In fact there even is a State 0, when the state is initialized and nothing has happened yet.

We will create as many actions as they are different states in the reducer as the reducer states depend on the actions

products.actions.ts

export enum ProductActionTypes {
  FETCH_PRODUCTS = '[Products] Fetch products',
  FETCH_PRODUCTS_SUCCESS = '[Products] Fetch products success',
  FETCH_PRODUCTS_FAIL = '[Products] Fetch products fail',
}
export class FetchProducts implements Action {
  readonly type = ProductActionTypes.FETCH_PRODUCTS;
}
export class FetchProductsSuccess implements Action {
  readonly type = ProductActionTypes.FETCH_PRODUCTS_SUCCESS;
  constructor(public products: Product[]) { }
}
export class FetchProductsFail implements Action {
  readonly type = ProductActionTypes.FETCH_PRODUCTS_FAIL;
  constructor(public payload: ErrorData) { }
}
export type ProductsActions =
  | FetchProducts
  | FetchProductsSuccess
  | FetchProductsFail;

products.reducer.ts

First, let's declare the state properties and the initial state (State 0 😉)

export interface ProductsState {
  loading: boolean;
  products: Product[];
}
export const productsInitialState: ProductsState = {
  loading: false,
  products: null,
};

Then let's listen for actions and manage the state accordingly

export function productsReducer(
  state = productsInitialState,
  action: ProductActions
): ProductsState {
  switch (action.type) {
    case ProductActionTypes.FETCH_PRODUCTS: {
      return {
        ...state,
        loading: true,
      };
    }
    case ProductActionTypes.FETCH_PRODUCTS_SUCCESS: {
      return {
        ...state,
        products: action.products,
        loading: false,
        loaded: true,
      };
    }
    case ProductActionTypes.FETCH_PRODUCTS_FAIL: {
      return {
        ...state,
        loading: false,
        loaded: false,
      };
    }
    default: {
      return state;
    }
  }
}

Effects

Once actions have been dispatched and states have been initialized, we need to take care of the side effects.

Effects are what's going to help you isolate services from components by listening to the dispatched actions. They can also trigger new events by dispatching new actions.

Let's explain it with an example. I want my products service to be called when the "Fetch products" action is dispatched, but I also want it to dispatch a new action once it has succeeded or failed don't I ?

products.effects.ts

First let's inject the services I need. Here, Actions is a stream which contains all the dispatched actions.

constructor(
    private actions$: Actions,
    private readonly productsService: ProductsService,
    private readonly errorService: ErrorService,
  ) { }

Then let's create our first effect:

@Effect()
public fetchProducts$ = this.actions$.pipe(
    ofType<FetchProducts>(ProductActionTypes.FETCH_PRODUCTS),
    switchMap(() => this.productsService.fetchProducts().pipe(
      map((products: Product[]) => new FetchProductsSuccess(products)),
      catchError((error: ErrorData) => of(new FetchProductsFail(error)))),
    ),
  );

What this effect is saying is:

Listen to all dispatched action for an action with the FetchProduct type If an action of this type is dispatched, then call the products service to fetch products. If the service call is a success then dispatch a FetchProductsSuccess action (passing it the result of the service call) If the service call fails, then dispatch a FetchProductsFail action. The action dispatched on success doesn't need an effect as it's only there to change the products state, remember ?

case '[Products] Fetch products success': {
      return {
        ...state,
        products: action.products,
        loading: false,
      };
    }

So, I dispatch a FetchProductsSuccess action, feed it the data I just got from the service, and guess who's waiting for it: the reducer.

Finally, in this case, I created an effect to display an error message if the service fails to fetch the products. By default, an effect will always dispatch a new action, but you can override this by adding { dispatch: false }. My effect will therefore call the service and then nothing more happens.

@Effect({ dispatch: false })
public fetchProductsFail$ = this.actions$.pipe(
    ofType<FetchProductsFail>(ProductActionTypes.FETCH_PRODUCTS_FAIL),
    map((action: FetchProductsFail) => action.payload),
    tap((error: ErrorData) => this.errorService.displayError(error)),
  );

This brings us to the last step "Selectors". If you remember, in our component, we dispatched the action this.store.dispatch(new FetchProducts());. That's the way to go, but, nobody in this component is watching for the state changes, so nothing visible should happen.

Selectors

Selectors are function that will help you get the "pieces" of your states you need.

In my example, I need to get the products and the loading state of my products state.

products.selector.ts

export const getProductsState = createFeatureSelector<ProductsState>('products');
export const getLoading = createSelector(
  getProductsState,
  (state: ProductsState) => state.loading
);
export const getProducts = createSelector(
  getProductsState,
  (state: ProductsState) => state.products
);

To use a selector, you have to call the store like follows:

products.component.ts

public products$: Observable<Product[]> = this.store.pipe(
    select(getProducts),
  );
public loading$: Observable<boolean> = this.store.pipe(
    select(getLoading)
  );

Using the async pattern in the Html file prevents from having to clean up the observables in the component's onDestroy method. The cleaning is done automatically when leaving the component.

product.component.html

<p *ngIf="loading$ | async"> Loading </p>
<ul *ngIf="products$ | async as products">
  <li *ngFor="let product of products">{{ product.name }}</li>
</ul>

Declaring the store in the App module Note the StoreDevtoolsModule which is very useful when debugging a NgRx application 👌.

[...]
import { reducers } from './core/store/reducers';
import { effects } from './core/store/effects';
  imports: [
    [...],
    StoreModule.forRoot(reducers, { runtimeChecks: { strictStateImmutability: true, strictActionImmutability: true } }),
    EffectsModule.forRoot(effects),
    StoreDevtoolsModule.instrument(),
  ]

Conclusion

This is the end of this small introduction to NgRx. You obviously can do much more stuff with it, like manage your app router, use entities to manage state collections and plenty of other magical things.

As you can see, for just a simple webapp, it might be just too complex to setup. In the example above, I only did the work for one state, one component and a few actions. The magic really starts operating when your app becomes complex, things are always at the place you expect them to be, your components are isolated from the services and using the devtools, you can easily debug and see the action/data flow of your app.

Just below are some links including the GitHub project for the example above.

I hope you appreciated my first tech article, I'll be happy to discuss it over even if you disagree 😇.

Links

Live example: https://5f1246c2a5e2da029b87fe44--hungry-bhabha-4ea98a.netlify.app/

GitHub: https://github.com/MatthieuHahn/ngrx

NgRx full documentation: https://ngrx.io/

Credits

I'd really like to thank Julien and Lukasz from the Kumojin team who waited patiently for me to be able to move to Canada for more than 8 months.

Kudos to Faustine and Pierre who took the time to explain NgRx to me.

Kumojin Tech