Welcome to Angular Challenges #5.

This series of Angular challenges aims to sharpen your skills with real-world examples. You'll submit your work via a PR for review, just as you would in a professional project or when contributing to Open Source Software.

The fifth challenge focuses on improving architectural practices by refactoring a small Todo application step by step. You'll build scalable, readable components using Angular, emphasizing reactive code with clear separation of concerns and optimal User and Developer Experience. We'll work with @Ngrx/component-store to manage local component state, along with @tomalaforge/ngrx-callstate-store to handle loading and error conditions.

If you haven't attempted the challenge yet, go ahead and try it first on Angular Challenges, then return here to compare your approach with mine. (You can also submit a PR for review.)


Here's the starting point for this challenge:

@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

Problems in the current code:

  • A single component handles all concerns: HTTP requests, state management, and UI logic.
  • There is no handling for loading or error states, which hurts the user experience.
  • Type safety is missing; using any should be avoided as much as possible.
  • Properties are set imperatively, lines of code only get worse with growth.
  • Void methods mutate properties directly, making debugging difficult as the component grows.
  • Manual subscriptions are made without proper cleanup.

The first step is to move all HTTP calls into a dedicated singleton Service. This allows the logic to be reused anywhere by simply injecting it into 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 providedIn: 'root' on the service creates a unique, tree-shakable instance. For further details, refer to this article.


The next action is to create a dedicated service for the component that holds its state. This is known as a Store. While many applications use a global store for the entire app's state, components also have local state—data not shared app-wide (though it can be shared with children).

To achieve this locally, we're using @Ngrx/component-store.

Other state management libraries like Akita, RxAngular, Elf, MiniRx Store, or Subject-as-a-Service exist, but this article focuses on NgRx Component Store.

Let's set up the store in 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 minimal setup creates a Component Store. Here's what's happening:

  • OnStoreInit and OnStateInit are lifecycle hooks. To use them, the store must be provided with the provideComponentStore helper. OnStoreInit fires immediately after store instantiation, followed by OnStateInit once the state is ready. Both hooks execute only once.
  • Selectors extract specific slices of state and are shareable. In RxJS, shareable observables can be subscribed to multiple times without re-running the computation.
  • Updaters create pure functions that take the current state and return updated state. Compared to patchState and setState, updaters receive the current state as an input.
  • Effects manage side-effects, such as HTTP requests or other asynchronous tasks.

For deeper documentation details, explore the official NgRx Component Store docs.

Important: Notice the @Injectable decorator with no providedIn: 'root'—we want the store scoped to the lifecycle of the component it's provided to.

Understanding injectable services in Angular is crucial; check out this guide for more.

To bind the store to our component, we provide it at the component level:

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

Note: As highlighted, using provideComponentStore is required when leveraging the OnStateInit and OnStoreInit lifecycle hooks. This is where we'll place initialization logic.

Now, we carve out the state shape: a collection of todos, plus loading and error indicators.

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

Next, we'll define selectors for reading state and construct a consolidated View Model for the template.

A View Model combines all properties the template needs into one object. This way, the component subscribes to a single observable and passes a single stream along.

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

Notes: Starting with @Ngrx v15, defining a viewmodel has been streamlined, as shown below:

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

Now we'll create all the necessary effects to load, modify, and remove our todo tasks.

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 flip the loading indicator on, then invoke the service's getAllTodo method, piping the result to NgRx's tapResponse operator to manage the response. This operator requires handling errors and prevents the effect from breaking.

Ensure you update the loading flag even when an error occurs.

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 resembles fetch, except it accepts an id input, which is a number. Consequently, the effect's generic type is number.

If clarity is needed, you could express the effect this way:

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

An updater is a pure function: it accepts the current state as input and produces a new, immutable state object.

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

Finally, we assemble the main 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

Inside the template, we subscribe to the viewmodel using the ngrxLet directive from the @ngrx/component package. Then we address loading and error states while rendering the todo list.

This component holds no logic; it simply passes data and actions to the relevant services.

Remaining Issues:

  • Inside the component store, using separate properties for loading and error states is fragile, since both need updating on errors.
  • The loading and error indicators are global, applying to all items at once. Ideally, each item would have its own indicator.

To fix the first issue, we’ll bring in a small library, ngrx-callstate-store, which augments the state to handle callstate automatically.

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

We can remove all manual loading and error state, rewriting effects as follows (shown here for the update effect; fetch and delete follow the same pattern)

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 final task is to break the component into smaller, focused units. It's always wise to separate data types into their own components for better organization.

Let's introduce 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
  • This component is provided its own store instance, meaning each todo item gets an isolated store. This design enables per-item loading or error states.
  • An @Input setter patches the state; all logic stays inside the store. The component is just a bridge between template and store.
  • The update and delete methods redirect to the store in the same manner.
@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 TodoStore is injected into TodoItemStore, always returning the same instance across all children. This reference allows updating the TODO list. TodoItemStore primarily manages loading and error states.
  • Both updateTodo and deleteTodo methods first set the current callState to LOADING, then perform the HTTP request, updating the parent state on success, or setting an error state on failure.

That wraps up this mini Todo application. Adding a form for new todos would be nice, but it's outside this challenge's scope. The complete code is available via this Pull Request here. (To run it, clone the project, switch to the solution branch, and execute nx serve crud).


I hope this fifth challenge was both informative and fun.

If you gained something valuable from this post, please support the effort by liking ❤️❤️ to broaden its reach. Also, share it with colleagues who may benefit. Your support is highly appreciated.

👉 Check out more challenges at Angular Challenges, and I’ll be glad to review your submissions!

Follow me on Twitter or Github. Reach out anytime for further questions.