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:
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.
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.
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.
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:
In short, I usually don't recommend it. And I said usually! :)
J'espère que vous avez aimé ce court article ! En résumé :
Je suis sûr que si vous suivez ces principes, vos applications seront beaucoup plus faciles à maintenir !
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!