Discover the power of @Ngrx/component-store to create a Local Component State

achtlos

thomas

Posted on December 26, 2022

Discover the power of @Ngrx/component-store to create a Local Component State

Welcome to Angular challenges #5.

The aim of this series of Angular challenges is to increase your skills by practicing on real life exemples. Moreover you will have to submit your work though a PR which I or other can review; as you will do on real work project or if you want to contribute to Open Source Software.

The fifth challenge will teach you some good practices for architecture and creating scalable and readable components using Angular by refactoring a small Todo Application step by step. You will learn how to create reactive code with good separation of concerns and a great User and Developer eXperience. We will use and discover @Ngrx/component-store to create our local Component Store as well as @tomalaforge/ngrx-callstate-store to handle our loading and error states.

If you haven't done the challenge yet, I invite you to try it first by going to Angular Challenges and coming back afterward to compare your solution with mine. (You can also submit a PR that I'll review)


Starting the challenge, we have the following code.

@Component({
  standalone: true,
  imports: [CommonModule],
  selector: 'app-root',
  template: `
    <div *ngFor="let todo of todos">
      {{ todo.title }}
      <button (click)="update(todo)">Update</button>
    </div>
  `,
})
export class AppComponent implements OnInit {
  todos!: any[];

  constructor(private http: HttpClient) {}

  ngOnInit(): void {
    this.http
      .get<any[]>('https://jsonplaceholder.typicode.com/todos')
      .subscribe((todos) => {
        this.todos = todos;
      });
  }

  update(todo: any) {
    this.http
      .put<any>(
        `https://jsonplaceholder.typicode.com/todos/${todo.id}`,
        JSON.stringify({
          todo: todo.id,
          title: "randText(),"
          body: todo.body,
          userId: todo.userId,
        }),
        {
          headers: {
            'Content-type': 'application/json; charset=UTF-8',
          },
        }
      )
      .subscribe((todoUpdated: any) => {
        this.todos[todoUpdated.id - 1] = todoUpdated;
      });
  }
}
Enter fullscreen mode Exit fullscreen mode

Issues: 

  • Everything is located inside a single component (http calls, state management, ui).
  • No loading or error management. (not very user friendly)
  • No Type checking (avoid the type any as possible)
  • All properties are set declaratively.
  • Void method are mutating some properties. Hard to debug as your component will grow.
  • Manual subscribe without unsubscription

First, we need to move all HTTP calls to a dedicated singleton Service. This way, we can use this code everywhere by injecting it inside into our components.

// singleton service provided inside the root injector. 
@Injectable({ providedIn: 'root' })
export class TodoService {
  private http = inject(HttpClient);

  getAllTodo = () =>
    this.http.get<Todo[]>(/*...*/);

  update = (id: number) =>
    this.http.put<Todo>(/*...*/);

  delete = (id: number) =>
    this.http.delete<void>(/*...*/);
}
Enter fullscreen mode Exit fullscreen mode

Setting our service providedIn: 'root' create a unique instance and a tree-shakable service. If you want more detail, I invite you to read this article. 


The second action is to create a service dedicated to our component that will hold its state. This component is called a Store . In most application, we will have a global store. A global store holds the state for the entire application. However each component can also have a local state. that is not shared across the application. (although it can be shared among its children)

For this purpose, we will use @Ngrx/component-store

You can use any other state management libraries like Akita, RxAngular, Elf, MiniRx Store, Subject as a Service … But I will focus on Component Store of NgRx.

Let's setup our store app.store.ts

 

@Injectable()
export class AppStore 
  extends ComponentStore<{/*State definition*/}> 
  implements OnStateInit, OnStoreInit {

  // selectors

  // updaters

  // effects

  ngrxOnStoreInit() {
    this.setState(/*initial state*/)
  }

  ngrxOnStateInit() {
    /*initialisation logic*/
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the minimal setup to create a CS. Let's go though it quickly:

  • OnStoreInit and OnStateInit are two lifecycle hooks. In order to use them, we need to provide our store using the provideComponentStore helper function. OnStoreInit is called immediately after the store is instantiated. OnStateInit is called after the state is created. Both hooks are only called once. 
  • selectors are functions that extract a specific piece of state and are shareable. In RxJS, shareable observables can be called as many times as desired without being recomputed.
  • updaters lets us create pure function to update the state. In comparison with patchState and setState , updaters have the current state as input. 
  • effects handles side-effects like HTTP calls or any asynchronous task. 

If you want more details or dig deeper into the documentation, you can go here.

Important Note: It's important to notice the @Injectable decorator. We didn't add providedIn: 'root' because we want our service to be tied to the lifecycle of the component in which it will be provided.

If you want more details on how injectable services work in Angular, you should read this article.

In order to tie our store to our component, we need to provide it at the component level:

@Component({
  /*...*/
  providers: [provideComponentStore(AppStore)],
  /*...*/
})
Enter fullscreen mode Exit fullscreen mode

Remark: As mentioned previously, we need to provide our store through provideComponentStore to use the OnStateInit and OnStoreInit lifecycle hooks inside our store. We will use these hooks to do some initialization logic.

Now, let's define our state. We will store a list of our Todos, a loading and en error indicators.

interface AppState {
  todos: Todo[];
  loading: boolean;
  error?: string;
}
Enter fullscreen mode Exit fullscreen mode

Then we need some selectors to read our state and we will create a View Model for our template.

A View Model is an object that combines all the properties needed in our template. This way, we only need to subscribe to one observable and pass one stream though our component.

private readonly todos$ = this.select((state) => state.todos);
private readonly loading$ = this.select((state) => state.loading);
private readonly error$ = this.select((state) => state.error);

readonly vm$ = this.select(
  {
    todos: this.todos$,
    loading: this.loading$,
    error: this.error$,
  },
  { debounce: true }
);
Enter fullscreen mode Exit fullscreen mode

Remarks: Since @Ngrx v15, the definition of our viewmodel has been simplified. This is similar to:

readonly vm$ = this.select(
  this.todo$,
  this.loading$,
  this.error$,
  (todos, loading, error) => ({
    todos,
    loading,
    error,
  }),
  { debounce: true }
);
Enter fullscreen mode Exit fullscreen mode

The next step is to create all the effects needed to fetch, update and delete our todo items.

Fetch:

// fetchTodo takes no input parameters
readonly fetchTodo = this.effect<void>(
  pipe(
    tap(() => this.patchState({ loading: true })),
    switchMap(() => this.todoService.getAllTodo().pipe(
        tapResponse(
           // success logic
          (todos) => this.patchState({ todos, loading: false }),
           // failure logic
          (error: Error) =>
            this.patchState({ error: error.message, loading: false })
        )
      )
    )
  )
);
Enter fullscreen mode Exit fullscreen mode

We first set our loading indicator to true, then we call our HTTP service's getAllTodo method and we the tapResponse operator from NgRx to handle our response. This operator requires us to handle our error case and prevents us to killing the effect.

Don't forget to update the loading indicator in case of an error.

Update:

// updateTodo takes a todo id as parameter
readonly updateTodo = this.effect<number>(
  pipe(
    tap(() => this.patchState({ loading: true })),
    switchMap((id) => this.todoService.update(id).pipe(
        tapResponse(
          (todo) => this.updateTodos(todo),
          (error: Error) =>
            this.patchState({ error: error.message, loading: false })
        )
      )
    )
  )
);

// CS updater function to replace the new Todo item inside our Todo array
private readonly updateTodos = this.updater((state, todo: Todo) => ({
  error: undefined,
  loading: false,
  todos: state.todos.map((t) => (t.id === todo.id ? { ...todo } : t)),
}));
Enter fullscreen mode Exit fullscreen mode

The update effect logic is similar to the fetch effect. The main difference is that the update effect has an input id of type number. So we set the generic type of our effect to number .

You can write your effect in the following way if you find it more readable:

readonly updateTodo = this.effect((id$: Obsersable<number>) => 
  id$.pipe(
    // ... 
    )
  )
);
Enter fullscreen mode Exit fullscreen mode

The updater function allows us to define a pure function that takes the current state as input and produces a new, immutable state as output.

Delete: (similar to update)

readonly deleteTodo = this.effect<number>(
  pipe(
    tap(() => this.patchState({ loading: true })),
    switchMap((id) => this.todoService.delete(id).pipe(
        tapResponse(
          () => this.deleteTodoState(id),
          (error: Error) =>
            this.patchState({ error: error.message, loading: false })
        )
      )
    )
  )
);

private readonly deleteTodoState = this.updater((state, todoId: number) => ({
  error: undefined,
  loading: false,
  todos: state.todos.filter((todo) => todo.id !== todoId),
}));

Enter fullscreen mode Exit fullscreen mode

The last step is to build our component:

@Component({
  standalone: true,
  imports: [NgIf, NgFor, MatProgressSpinnerModule, LetModule],
  providers: [provideComponentStore(AppStore)],
  selector: 'app-root',
  template: `
    <ng-container *nrxLet="vm$ as vm">
      <mat-spinner [diameter]="20" color="blue" *ngIf="vm.loading">
      </mat-spinner>
      <ng-container *ngIf="vm.error; else noError">
        Error has occured: {{ vm.error }}
      </ng-container>
      <ng-template #noError>
        <div *ngFor="let todo of vm.todos">
          {{ todo.title }}
          <button (click)="update(todo.id)">Update</button>
          <button (click)="delete(todo.id)">Delete</button>
        </div>
      </ng-template>
    </ng-container>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
  private appStore = inject(AppStore)

  // only one stream goes into our component
  vm$ = this.appStore.vm$;

  update(todoId: number) {
    this.appStore.updateTodo(todoId);
  }

  delete(todoId: number) {
    this.appStore.deleteTodo(todoId);
  }
}
Enter fullscreen mode Exit fullscreen mode

In our template, we start by subscribing to our viewmodel using the ngrxLet directive provided by @ngrx/component package. We then handle our loading and error state and create our list of todos.

Our component is very simple and doesn't contain any logic. It just acts as a proxy, passing information to the corresponding service.

Issues:

  • Inside the component store, having two separate properties to handle the loading and error state is error-prone as we need to update both in case of an error. 
  • In our template, the loading and error indicator are global, which means they apply to all items. It would be nicer to have one indicator per item.

To resolve the first issue, we will use a small library called ngrx-callstate-store. The library enhances the provided state to handle the callstate for us.

@Injectable()
export class AppStore
  extends CallStateComponentStore<{todos: Todo[]}>
  implements OnStateInit, OnStoreInit
{/*...*/}
Enter fullscreen mode Exit fullscreen mode

We can delete everything related to the loading or error state and our effects can be rewritten as follow (Only the update effect is shown, but the fetch and the delete effects can be updated in a similar fashion)

readonly updateTodo = this.effect<number>(
  pipe(
    tap(() => this.startLoading()), // updater to patch our loading state
    switchMap((id) => this.todoService.update(id).pipe(
        tapResponse(
          // we could use stopLoading() 
          // but we would still need to update our todoList via an updater
          (todo) => this.updateTodos(todo),
          (error: unknown) => this.handleError( error ) // handle our UNKNOWN error
        )
      )
    )
  )
);

private readonly updateTodos = this.updater((state, todo: Todo) => ({
  ...state,
  callState: 'LOADED',
  todos: state.todos.map((t) => (t.id === todo.id ? { ...todo } : t)),
}));
Enter fullscreen mode Exit fullscreen mode

The last issue is to split our component into smaller chunks. Is is always a good practice to isolate your data types into their own components and to have smaller component.

Let's create a TodoItemComponent :

@Component({
  //...
  providers: [provideComponentStore(TodoItemStore)],
  template: `...`
})
export class TodoItemComponent {
  @Input() set todo(todo: Todo) {
    this.todoItemStore.patchState({ todo });
  }

  private todoItemStore = inject(TodoItemStore);

  vm$ = this.todoItemStore.vm$;

  update(todoId: number) {
    this.todoItemStore.updateTodo(todoId);
  }

  delete(todoId: number) {
    this.todoItemStore.deleteTodo(todoId);
  }
}
Enter fullscreen mode Exit fullscreen mode
  • We provide a store that is tied to this component. This means that each todo items will have its own instance of the store. This is necessary in order to create a loading or error state for each item. 
  • We create a setter on the @Input property to patch our state. The entire logic is done inside our store. The component is only a proxy between the template and the store. 
  • The same is true for update and delete methods, which we redirect to our store.
@Injectable()
export class TodoItemStore extends CallStateComponentStore<{ todo: Todo }> {
  private todoService = inject(TodoService);
  private todosStore = inject(TodosStore);

  private readonly todo$ = this.select((state) => state.todo);

  readonly vm$ = this.select(
    {
      todo: this.todo$,
      loading: this.isLoading$,
      error: this.error$,
    },
    { debounce: true }
  );

  readonly updateTodo = this.effect<number>(
    pipe(
      tap(() => this.startLoading()),
      switchMap((id) => this.todoService.update(id).pipe(
          tapResponse(
            (todo) => {
              this.stopLoading();
              this.todosStore.updateTodo(todo);
            },
            (error: unknown) => this.handleError(error)
          )
        )
      )
    )
  );

  readonly deleteTodo = this.effect<number>(
    pipe(
      tap(() => this.startLoading()),
      switchMap((id) => this.todoService.delete(id).pipe(
          tapResponse(
            () => this.todosStore.deleteTodoState(id),
            (error: unknown) => this.handleError(error)
          )
        )
      )
    )
  );
}
Enter fullscreen mode Exit fullscreen mode
  • The parent store TodoStore is injected inside TodoItemStore . The parent store will have the same instance among all its children. We need to get the store instance to update the Todo list. TodoItemStore will mostly handle the loading and error state. 
  • The updateTodo and deleteTodo methods set the current callState to LOADING , then make a HTTP request and update the parent state on success or update the current callstate with an error otherwise. 

That's it; we have build a nice todo app. We could add a small form to add new todos to the list, but that is beyond the scope of this challenge. You can find the final code in the form of a Pull Request here. (If you want to get it up and running, you can clone the project, check out the solution branch and run nx serve crud)


I hope you enjoyed this fifth challenge and learned some new Angular techniques.

If you found this article useful, please consider supporting my work by giving it some likes ❤️❤️ to help it reach a wider audience. Don't forget to share it with your teammates who might also find it useful. Your support would be greatly appreciated.

👉 Other challenges are waiting for you at Angular challenges. Come and try them. I'll be happy to review you!

Follow me on Twitter or Github to read more about upcoming Challenges! Don't hesitate to ping me if you have more questions.

💖 💪 🙅 🚩
achtlos
thomas

Posted on December 26, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related