DEV Community

Cover image for Removing boilerplate code in Ngrx component store
Gaurav Soni for This is Angular

Posted on • Edited on

Removing boilerplate code in Ngrx component store

Introduction

Ngrx component store is a great package for state management at component level in angular. For small applications and applications having isolated component trees, which require their own state and that do not need to be shared, it is a great fit. It comes with the power of a push based mechanism but at the level of service. In this article, I will assume you have basic understanding about the @ngrx/component-store. So, we will not focus on discussing the basics of component store. Instead, we will talk about removing duplicate code while using the component store. We will write a lot of code. So, let’s get started.

App structure

Below is the structure of our demo application:-

├── src/
│   ├── app/
│   │    ├── albums/
│   │    │     ├── albums.component.ts
│   │    │     ├── albums.component.html
│   │    │     ├── albums.component.css
│   │    │     ├── albums.store.ts
│   │    ├── users/
│   │    │     ├── users.component.ts
│   │    │     ├── users.component.html
│   │    │     ├── users.component.css
│   │    │     ├── users.store.ts
│   │    │── app.component.html
│   │    │── app.component.css
│   │    │── app.component.ts
│   │    │── app.module.ts
│   │    │── base-component.store.ts
│   │    │── count.component.ts
│   │
│   ├── assets/
│   ├── environments/
│   ├── favicon.ico
│   ├── index.html
│   ├── main.ts
│   ├── polyfills.ts
│   ├── styles.css
│   └── test.ts
├── .browserslistrc
├── karma.conf.js
├── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

We have two components:- Users and Albums. Both have their own component stores. We also have one base component store. We will talk about it later in the article. Also we have one count compnent to show the total number of items loaded.
Let’s start first with creating a generic state interface.

Generic State interface

This interface represents the state that each component store will have whenever we create a new state. Below is the code snippet for this,

export type LOADING = 'IDLE' | 'LOADING';
type CallState = LOADING | HttpErrorResponse;

export interface GenericState<T> {
  data: T;
  callState: CallState;
  totalCount: number;
}
Enter fullscreen mode Exit fullscreen mode

The GenericState interface accepts a generic type <T> which represents the structure of our data property. Out data can either be a collection of itmes or a single item. Then we have callState which will be either of type LOADING or HttpErrorResponse. We can also create them separately as loading and error. But I would prefer to have them in a single property. Then we have the totalCount which will be the length of total items if our data is a collection of items, otherwise(if data is an object or single item) we can set it to 0 in order to ignore it.

BaseComponentStore

Now let's move to the next step and create a BaseComponentStore which will be extended by albums and users component store. The basic idea behind creating this is to provide boilerplate code for our both stores.

@Injectable()
export class BaseComponentStore<
  T extends GenericState<unknown>
> extends ComponentStore<T> {
  baseSelector = this.select(({ callState, totalCount }) => ({
    totalCount,
    loading: callState === 'LOADING',
    error: callState instanceof HttpErrorResponse ? callState : undefined,
  }));
  protected updateError = this.updater((state, error: CallState) => ({
    ...state,
    callState: error,
  }));

  protected setLoading = this.updater((state) => ({
    ...state,
    data: undefined,
    callState: 'LOADING',
  }));

  protected updateSuccess = this.updater((state, data: T['data']) => ({
    ...state,
    data,
    totalCount: Array.isArray(data) ? data.length : 0,
    callState: 'IDLE',
  }));
}
Enter fullscreen mode Exit fullscreen mode

Our BaseComponentStore accepts the generic type T which by default extends the GenericState of unknown. We are using unknown here because we don't’ know the type of data here. But we are sure about the types of other properties such as callState and totalCount. This BaseComponentStore extends the ComponentStore in order to have access to state and other methods.
Next we are creating the baseSelector. This will be used to get all other properties required by the component. Error, loading and totalCount are common properties that are required by the components. So, it's a good idea to have them in the base selector. We can add more properties to this selector based on our requirement.

Next we have the updateError method. Again, most of the time the errors are handled in the common way. So, we can have this method in our base component store.
Similarly, we have setLoading method to update the loading state.
Then we have updateSuccess method to update the data in the component store. We are assuming here that the data is simply the new list or new item. So it's easy to set. So we are updating the data, setting up the totalCount with the length of items and updating the callState back to IDLE.

Now with this we have our boilerplate/duplicate code inside the BaseComponentStore which gives benefit to all other stores which will extend it.

Implementing AlbumsStore

Now that our base component store is ready, let’s start with creating an AlbumsStore which we will use in AlbumsComponent.
Let’s first create required interfaces,

interface Album {
  id: number;
  userId: number;
  title: string;
}

interface AlbumViewModel {
  albums: Album[];
  loading: boolean;
  totalCount: number;
  error: HttpErrorResponse;
}
Enter fullscreen mode Exit fullscreen mode

We have the Album interface which has id, userId and title properties. Then we are creating a AlbumViewModel interface which is used to build the viewModel. A viewModel is the pattern used to expose the single observable instead of multiple observables which will be used by the component.

Let’s move to the next step of creating AlbumsStore.

@Injectable()
export class AlbumsStore extends BaseComponentStore<GenericState<Album[]>> {
  readonly albums$ = this.select((state) => state.data);
  readonly vm$: Observable<AlbumViewModel> = this.select(
    this.baseSelector,
    this.albums$,
    (state, albums) => ({ ...state, albums })
  );
  constructor(private readonly http: HttpClient) {
    super({
      data: [],
      callState: 'IDLE',
      totalCount: 0,
    });
  }

  readonly getAlbums = this.effect((params$: Observable<unknown>) => {
    return params$.pipe(
      tap((_) => this.setLoading()),
      switchMap((_) =>
        this.http
          .get<Album[]>('https://jsonplaceholder.typicode.com/albums')
          .pipe(
            tapResponse(
              (users: Album[]) => this.updateSuccess(users),
              (error: HttpErrorResponse) => this.updateError(error)
            )
          )
      )
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

Our AlbumsStore extends the BaseComponentStore by providing the Album[] as a type for the GenericState<T>. Now the first thing we can notice here is that we are not creating a new interface for the state(something like AlbumsState). All the common properties, totalCount and callState are always there in the BaseComponentStore via GenericState. So we don’t need that. Next we are creating albums$. This is just a mapping of data to the albums. In our component, instead of data, using the album as property might be the better naming convention.

After that we have our vm$ which is the single observable exposing multiple properties. Now again we can notice the benefit of creating the boilerplate code. We are not adding the loading, error and totaCount here since they will be always coming from baseSelector.

Now we have our selectors done, let’s start with initializing the state. We are initializing it via calling the parent constructor(as per component store convention) with our default state.

Next we have the effect which will fetch the albums from the server. Notice that we are using the setLoading method from our BaseComponentStore to update the callState to LOADING. This will be used in the component to show the loader. Similarly, we are also using the updateSuccess and updateError to set the data and error in the state. Also, ngrx component store provides tapResponse operator to gracefully handle the errors. So we are using it.

Using AlbumsStore in the component

We are ready to use the AlbumStore inside our AlbumsComponent. Let’s have a look into album.component.ts,

@Component({
  selector: 'app-albums',
  templateUrl: './albums.component.html',
  styleUrls: ['./albums.component.css'],
  providers: [AlbumsStore],
})
export class AlbumsComponent implements OnInit {
  vm$ = this.store.vm$;
  constructor(private store: AlbumsStore) {}

  ngOnInit() {
    this.store.getAlbums({});
  }

  fetch() {
    this.store.getAlbums({});
  }
}
Enter fullscreen mode Exit fullscreen mode

Our AlbumsComponent is simple. It has vm$ observable as property. We are calling our effect on ngOnInit to fetch the albums. After this we have one method fetch which we can call whenever we want to re-fetch our data.

Let's look at the album.component.html file as well.

<ng-container *ngIf="vm$ | async as vm">
  <button (click)="fetch()">Fetch Albums</button>
  <ng-container *ngIf="!vm.loading; else loading">
    <count [count]="vm.totalCount"></count>
    <ng-container *ngFor="let album of vm.albums">
      <pre>ID: {{ album.id }}</pre>
      <pre>UserId: {{ album.userId }}</pre>
      <pre>title: {{ album.title }}</pre>
    </ng-container>
  </ng-container>
</ng-container>
<ng-template #loading>
  <div>Loading...</div>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

In the html file, we are using an async pipe to subscribe for the vm$ property. async pipe will automatically update our view whenever our vm$ changes. We have a button(Fetch Albums) to re-fetch the albums. Then we are showing the albums if we have the albums available otherwise we are showing the loading text. We are also showing the count of the albums via count component and then showing the album info via *ngFor. <count> is a simple component which accepts count as @Input and then showing them with message Total count: {{count}},

@Component({
  selector: 'count',
  template: `<h1>Total Count: {{count}}!</h1>`,
  styles: [`h1 { font-family: Lato; }`],
})
export class CountComponent {
  @Input() count: number;
}
Enter fullscreen mode Exit fullscreen mode

Implementing UsersStore

Next we can have the UsersStore and UsersComponent. The code snippet is identical to the album's feature. I am just adding the snippet for the UsersStore and the rest of the code can be seen at stackblitz.

interface User {
  id: number;
  name: string;
  username: string;
}

interface UserViewModel {
  users: User[];
  loading: boolean;
  totalCount: number;
  error: HttpErrorResponse;
}
Enter fullscreen mode Exit fullscreen mode
@Injectable()
export class UsersStore extends BaseComponentStore<GenericState<User[]>> {
  readonly users$ = this.select((state) => state.data);
  readonly vm$: Observable<UserViewModel> = this.select(
    this.baseSelector,
    this.users$,
    (state, users) => ({ ...state, users })
  );
  constructor(private readonly http: HttpClient) {
    super({
      data: [],
      callState: 'IDLE',
      totalCount: 0,
    });
  }

  readonly getUsers = this.effect((params$: Observable<unknown>) => {
    return params$.pipe(
      tap((_) => this.setLoading()),
      switchMap((_) =>
        this.http
          .get<User[]>('https://jsonplaceholder.typicode.com/users')
          .pipe(
            delay(300),
            tapResponse(
              (users: User[]) => this.updateSuccess(users),
              (error: HttpErrorResponse) => this.updateError(error)
            )
          )
      )
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

Everything is identical to the AlbumsStore. Instead of albums we have users here. Below is the gif of the working example,

Image description

With the help of our BaseComponentStore, we are able to remove a lot of duplicate code. Hence we need to write less code every time we create new component store and we will still get the same results.

Full code example can be found on below stackblitz link:-
https://stackblitz.com/edit/angular-ivy-rgps6q?file=src%2Fapp%2Fbase-component.store.ts

Top comments (0)