In this short article I want to show you how I like to structure my components with signals, with no external library. Of course things like NgRx would play a huge role to make our code more robust, but let's start simple!
First of all I define all of my states with signals:
export class TodoListComponent {
todos = signal<Todo[]>([]);
}
The same applies for inputs, too! If my component needs an input, I declare it with the new input()
function by Angular, which gives me a signal as well. And if it happens to be a route parameter, I use input.required()
.
Then, if I want to show some state that can be derived from another one, I always use computed
:
completedTodos = computed(() => this.todos().filter(t => t.completed));
Then, if you know me, you know how much I despise performing asynchronous side-effects directly inside class methods... 🤢
export class TodoListComponent {
todoService = inject(TodoService);
toggleTodo(id: string) {
this.todoService.toggle(id).subscribe(newTodo => ...);
}
}
Why you ask? Because if the method is the one who directly initiates the side-effect (in this case, calling subscribe
), you have no control over back-pressure.
Back-pressure can be summed up with this: what happens if the user toggles the todo while the previous call hasn't finished?
There are a number of problems, for example:
- Do we even want to perform the second call? Or wait for the first one to finish? Or should we cancel the first one?
- What if we toggle different items in a short time?
- What if we want to introduce some sort of debounce or throttle?
If you know RxJS (and if you're reading this, you should by now!) you know that the first problem is easily solved with the 4 Flattening Operators (mergeMap
, concatMap
, switchMap
, exhaustMap
).
Then, if you know RxJS quite well, you know that you can solve the second problem with an awesome operator called groupBy
!
But in order to use all of this goodness, you must have an Observable source, so... not a method.
Subjects
Think of a Subject like an open (not completed), empty Observable. It's the perfect tool to represent custom events.
All of the events in our component can be represented by Subjects:
export class TodoListComponent {
...
toggleTodo$ = new Subject<string>();
deleteTodo$ = new Subject<string>();
addTodo$ = new Subject<void>();
}
Then, our template can simply call them when necessary, instead of calling methods, for example:
<button (click)="deleteTodo$.next(todo.id)">delete</button>
Now that our sources are Observables, we can use our dear operators: let's create some effects.
Effects
I like to define my effects inside the constructor so that I can use the takeUntilDestroyed()
operator to clean up the effect when the component is destroyed! So, for example:
constructor() {
this.addTodo$.pipe(
concatMap(() => this.todoService.add())
takeUntilDestroyed()
).subscribe(newTodo => this.todos.update(todos => [...todos, newTodo]));
}
Here I'm using concatMap
in order to preserve the order of the responses, so that the todos are added in order. This means that there are no concurrent calls. I think it's perfect for add operations, but it may be the wrong choice for other calls: for instance, for a GET request, it's usually better to use exhaustMap
or switchMap
, depending on the use case.
I'm also using an approach which is called Pessimistic Update, which means that I wait for the call to end to update my internal state. This is a personal preference! You could add the todo right away, and then revert it back by using a catchError
if the API call errors out.
Then there's the actual effect
function from Angular which is meant to be used in conjunction with signals: I use this function for synchronization tasks. For example, when a parameter changes in the URL (referring to a new entity ID), I may want to update a form with the new 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());
})
}
Notice that we don't have control over back-pressure with this technique. For this kind of thing it's fine, but remember: that's why we still need RxJS in order to craft bug-free apps. Or another library which abstracts this complexity under the hood.
Going full Reactive is not always a good idea
Many states that we represent with signals could be technically be considered derived asynchronous states. For example, our Todo list could be considered a derived state 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())
));
This approach is similar to the one used by libraries such as TanStack Query, in which you manually invalidate a query when you need the new data. In other words, you always go to the server for each mutation.
This may be good in some scenarios, but there are 2 things to consider:
- It makes updating the state manually (optimistic updates) difficult. This is made easier by libraries such as TanStack Query, but doing it manually is a pain.
- It makes the code somewhat harder to grasp by most developers, this is what I see as a consultant working on this kind of stuff daily.
In short, I usually don't recommend it. And I said usually! :)
Conclusion
I hope you liked this short article! As a summary:
- Define your states as
signal
s - Define your derived states as
computed
signals - Define your asynchronous effect as
Observable
s - Define your synchronozation effects with
effect
s
I'm sure that if you follow these principles your apps will be much easier to maintain!
Top comments (1)
Thank you, very nice to read.
Bookmarked