Introduction

The Ngrx component store proves to be a powerful solution for managing state at the component level within Angular applications. It shines particularly in smaller projects or in scenarios where component trees remain isolated and do not require shared state. This approach combines the benefits of a push-based mechanism with the convenience of a service-level implementation. This article assumes you already have a working understanding of @ngrx/component-store, so we will not revisit its fundamentals. Instead, our focus shifts toward eliminating repetitive code patterns when working with the component store. Expect a substantial amount of code as we progress through the implementation.

App structure

Here is how our demo application is organized:

├── 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

Two components make up this application: Users and Albums. Each component maintains its own dedicated component store. Additionally, there is a shared base component store that both will leverage—more on that shortly. A count component also exists to display the total number of loaded items.
We will begin by defining a generic state interface.

Generic State interface

This interface outlines the state shape that every component store will adopt upon creation. The corresponding code snippet appears below:

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 parameter <T> that defines the structure of the data property. This data can represent either a collection of items or a singular object. Following that, we have the callState property, which can assume one of two forms: LOADING or HttpErrorResponse. While separate loading and error properties are possible, consolidating them into a single property is my preferred approach. Finally, totalCount tracks the number of items when data is an array; for object-type data, we set it to 0 to effectively disregard it.

BaseComponentStore

Now, we move forward by establishing a BaseComponentStore that both the albums and users component stores will extend. The primary motivation here is to house all the boilerplate logic within this shared class.

@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

This BaseComponentStore accepts a generic type T, which defaults to extending GenericState with a data type of unknown. Using unknown is intentional since the data type isn't known at this level, though the types for callState and totalCount are well-defined. By extending ComponentStore, we inherit access to the state and its associated methods.
We then define the baseSelector—a consolidated observable that the components rely on. Since properties like error, loading, and totalCount are universally required, placing them in this base selector is a sensible move. Additional properties can be appended to this selector as needs evolve.

Following that, we implement the updateError method. Error handling typically follows a consistent pattern, which makes it an ideal candidate for inclusion in our base class.
Likewise, a setLoading method is created to manage the loading state.
The updateSuccess method comes next, responsible for populating the store with data. It assumes the data passed in is either a fresh list or a new item, making assignment straightforward. This method updates the data property, sets totalCount based on the item count, and resets callState back to IDLE.

With these pieces in place, all the repetitive code now resides within BaseComponentStore, providing a clear advantage to any store that extends it.

Implementing AlbumsStore

With our foundational component store complete, it's time to construct the AlbumsStore, which will be injected into the AlbumsComponent.
First, we need to define the necessary 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

The Album interface consists of id, userId, and title properties. We also define an AlbumViewModel interface that facilitates the construction of a viewModel—a pattern that consolidates multiple observables into a single one for component consumption.

Proceeding to the next phase, we'll build the AlbumsStore itself.

@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 BaseComponentStore, passing Album[] as the type argument for GenericState<T>. A notable observation is the absence of a dedicated state interface (like AlbumsState). This is unnecessary because the BaseComponentStore already provides the shared properties like totalCount and callState through GenericState. Next, we create the albums$ observable—essentially a direct mapping from the generic data property to a more domain-specific name. Within our component, referring to albums rather than generic data offers clearer naming.

Following that, we define vm$—the single observable that exposes multiple state slices to the component. This again highlights the benefit of our pre-built boilerplate: we don't need to include loading, error, or totalCount here since they are automatically provided by the baseSelector.

With our selectors in place, we initialize the store's state. Per component store conventions, this is achieved by invoking the parent constructor with our default state object.

Finally, we set up the effect responsible for fetching albums from the server. Pay attention to how we leverage the setLoading method from BaseComponentStore to transition callState to LOADING, which the component then uses to display a loader. Similarly, we employ the updateSuccess and updateError methods for setting data and handling errors, respectively. The ngrx component store also offers the tapResponse operator for seamless error handling, and we make use of it here as well.

Integrating AlbumsStore into the component

Now we can integrate AlbumStore into AlbumsComponent. Let's examine what's happening in 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

The component stays minimal. It exposes a vm$ observable as a property. The effect that fetches albums gets triggered from ngOnInit. A dedicated fetch method is available for triggering data refreshes at any point.

Moving on to the template in album.component.html.

<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

The template subscribes to vm$ through the async pipe, which handles view updates automatically as the observable emits new values. A Fetch Albums button allows manual refreshes. The view conditionally renders either the album list or a loading indicator. Album count appears via the count component, and individual albums are displayed using *ngFor. The <count> component is straightforward: it receives a count through @Input and displays 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

Building the UsersStore

Following the same pattern, we can set up UsersStore and UsersComponent. The structure mirrors the albums feature almost exactly. I'm sharing the UsersStore snippet here; the remaining code is available on 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

The implementation is essentially a clone of AlbumsStore, with users replacing albums. Below is a gif demonstrating the working application,

Removing boilerplate code in Ngrx component store — figure 1

Through our custom BaseComponentStore, we significantly cut down on repetitive logic. This means creating new component stores requires considerably less code while producing identical outcomes.

The complete example is hosted on stackblitz at the following link:-
https://stackblitz.com/edit/angular-ivy-rgps6q?file=src%2Fapp%2Fbase-component.store.ts