Signals in My Component Architecture

This piece walks through my preferred approach for organizing Angular components using signals, relying purely on built-in functionality. Libraries like NgRx certainly add robustness, but the fundamentals can take us surprisingly far.

The first step involves declaring every piece of state as a signal:

export class TodoListComponent {
  todos = signal<Todo[]>([]);
}
Enter fullscreen mode Exit fullscreen mode

Inputs follow the same pattern. When a component requires an input, it uses Angular's newer input() function, which returns a signal. For route parameters, the input.required() variant applies.

Whenever a state can be derived from an existing one, the go-to tool is computed:

completedTodos = computed(() => this.todos().filter(t => t.completed));
Enter fullscreen mode Exit fullscreen mode

Those familiar with my work know my aversion to asynchronous side-effects living inside class methods... 🤢

export class TodoListComponent {
  todoService = inject(TodoService);

  toggleTodo(id: string) {
    this.todoService.toggle(id).subscribe(newTodo => ...);
  }
}
Enter fullscreen mode Exit fullscreen mode

The rationale? When a method directly triggers a side-effect — like invoking subscribe — you lose all control over back-pressure.

Back-pressure boils down to this question: what occurs when the user toggles a todo while the previous request is still pending?

Several concerns come into play:

  1. Should the second request be sent at all? Or should we await the completion of the first? Perhaps cancel the initial one?
  2. What if multiple items are toggled rapidly in succession?
  3. How would we introduce debounce or throttle mechanics?

Those versed in RxJS (which, at this point, you should be!) recognize that the first issue is elegantly addressed by the 4 Flattening Operators (mergeMap, concatMap, switchMap, exhaustMap).

And with deeper RxJS knowledge, the second problem finds its solution in the powerful groupBy operator!

Yet, to leverage all this functionality, an Observable source is mandatory — hence, not a method.

Subjects

Consider a Subject as an open (uncompleted), empty Observable. It serves as the ideal representation for custom events.

Every event within a component can be modeled using Subjects:

export class TodoListComponent {
  ...

  toggleTodo$ = new Subject<string>();
  deleteTodo$ = new Subject<string>();
  addTodo$ = new Subject<void>();
}
Enter fullscreen mode Exit fullscreen mode

The template can then reference these Subjects directly, as opposed to invoking methods, like so:

<button (click)="deleteTodo$.next(todo.id)">delete</button>
Enter fullscreen mode Exit fullscreen mode

With Observables as our sources, our cherished operators come into play: let's craft some effects.

Effects

My preference is to define effects within the constructor. This allows the takeUntilDestroyed() operator to handle cleanup when the component is destroyed. For example:

constructor() {
  this.addTodo$.pipe(
    concatMap(() => this.todoService.add())
    takeUntilDestroyed()
  ).subscribe(newTodo => this.todos.update(todos => [...todos, newTodo]));
}
Enter fullscreen mode Exit fullscreen mode

In this scenario, concatMap ensures response order is preserved, so todos appear sequentially. This implies no concurrent calls. It seems ideal for add operations, yet it might not suit other requests: for a GET, exhaustMap or switchMap often prove better, depending on the requirements.

The approach employed here is known as Pessimistic Update, meaning state updates occur only after the call completes. This is a personal choice! Alternatively, you could add the todo optimistically and revert it with a catchError if the API call fails.

Then there's Angular's effect function, intended for use with signals. I apply it for synchronization duties. For instance, when a URL parameter shifts (pointing to a new entity ID), the form might need updating with the fresh entity:

// This comes from the router
id = input.required<string>();

// Always stores the current invoice information
currentInvoice = toSignal(toObservable(this.id).pipe(
  switchMap(id => this.invoiceService.get(id))
));

constructor() {
  effect(() => {
    // Assuming the 2 structures match, every time we browse
    // to a new invoice, the form gets populated
    this.form.patchValue(this.currentInvoice());
  })
}
Enter fullscreen mode Exit fullscreen mode

Bear in mind, this technique provides no back-pressure control. For such cases it suffices, but that's precisely why RxJS remains essential for crafting bug-free applications. Alternatively, a library that encapsulates this complexity under the hood would work.

Full Reactivity: Not Always the Answer

Numerous states captured as signals could be labeled derived asynchronous states. The Todo list, for example, could be viewed as derived from the server:

// Trigger this when you need to refetch the todos
fetchTodos$ = new Subject<void>();

todos = toSignal(toObservable(this.fetchTodos$).pipe(
  switchMap(id => this.todoService.getAll())
));
Enter fullscreen mode Exit fullscreen mode

This mirrors strategies in libraries like TanStack Query, where queries get manually invalidated to fetch fresh data. Essentially, every mutation goes back to the server.

While viable in certain situations, two considerations arise:

  1. Manually updating state (optimistic updates) becomes challenging. Libraries like TanStack Query simplify this, but a manual approach is cumbersome.
  2. Comprehension for the average developer diminishes. In my daily consulting work, this is a noticeable hurdle.

In short, my typical advice is against it. And I did say typically! :)

Conclusion

Thank you for reading! Here's a quick recap:

  • Represent your state with signals
  • Represent derived state with computed signals
  • Represent asynchronous side-effects with Observables
  • Represent synchronization side-effects with effects

Adhering to these guidelines will certainly simplify the maintenance of your applications!


AccademiaDev

AccademiaDev: text-based web development courses!

My philosophy centers on offering focused, high-value content, avoiding the excessive length and fluff typical of conventional textbooks. Leveraging my experience as a consultant and trainer, these interactive, online resources deliver actionable insights via text, code examples, and quizzes—crafting an effective and engaging educational path.

Available courses