You can view the source code for this project by following this link: GitHub
Demo
- Buy milk
- Buy eggs
- Buy bread
Try to click into the add
button many times and see that task is added only once.
Intro
You have a button on a website. When a user click this button you want to send a HTTP request to the server. Even for this simple task there is a lot of cases to handle:
- show loading component when request is in progress
- handle HTTP error response
- block sending a next request until the previous one is not ended
export class ToDoComponent { private readonly todoService = inject(TodoService); readonly saveInProgress = signal(false);
save() { if (this.saveInProgress()) { return; }
this.saveInProgress.set(true);
this.todoService .save() .pipe(finalize(() => this.saveInProgress.set(false))) .subscribe(); }}
<button (click)="save()" [disabled]="saveInProgress()">Save</button>
As you can see there is a lot of boilerplate code. You have to repeat this code for every button which calls a asynchronous action.
Moving logic to directive
We can move this logic to a directive and reuse it in every place where we need it.
@Directive({ selector: "[appAsyncAction]", standalone: true, host: { "(click)": "handleClick()", "[disabled]": "inProgress()", },})export class AsyncActionDirective { action = input.required<Observable<unknown>>({ alias: "appAsyncAction" });
private readonly inProgress = signal(false); private readonly destroyRef = inject(DestroyRef);
handleClick() { if (this.inProgress()) { return; }
this.inProgress.set(true);
this.action() .pipe( takeUntilDestroyed(this.destroyRef), finalize(() => this.inProgress.set(false)), ) .subscribe(); }}
Now the directive calls subscribe
method and manage the subscription.
Additionally, it uses takeUntilDestroyed
operator to automatically unsubscribe when the component is destroyed.
Now we can use this directive in our component.
To do this we need to refactor our component a little bit.
We can remove saveInProgress
signal and replace it with appAsyncAction
directive.
export class ToDoComponent { private readonly todoService = inject(TodoService);
save() { return this.todoService.save(); }}
<button [appAsyncAction]="save()">Save</button>
As you can see we have removed a lot of boilerplate code from our component.
Problems with save()
method
There is one problem with this approach.
We call save()
method in the template.
That means that every time the template is rendered the save()
method is called.
To fix we cannot use save()
method directly.
We need to call it and store the result in a variable.
export class ToDoComponent { private readonly todoService = inject(TodoService);
save$ = this.getSaveAction();
private getSaveAction() { return this.todoService.save(); }}
<button [appAsyncAction]="save$">Save</button>
Problem with dynamic data
There is another problem with this approach.
If the data is dynamic we need to call getSaveAction()
method every time the data changes.
Right now we call getSaveAction()
method only once when the component is created.
That means when we subscribe to save$
observable it will always send the same data.
export class ToDoComponent { private readonly todoService = inject(TodoService); readonly form = new FormGroup({ title: new FormControl(""), completed: new FormControl(false), });
save$ = this.getSaveAction();
private getSaveAction() { const formData = this.form.value;
return this.todoService.save(); }}
To fix this problem we need to call getSaveAction()
lazy.
That means only call the getSaveAction()
method when the save$
observable is subscribed.
We can do this using defer operator.
defer allows you to create an Observable only when the Observer subscribes.
See defer
operator in action:
import { defer } from "rxjs";
export class ToDoComponent { private readonly todoService = inject(TodoService); readonly form = new FormGroup({ title: new FormControl(""), completed: new FormControl(false), });
save$ = defer(() => this.getSaveAction());
private getSaveAction() { const formData = this.form.value;
return this.todoService.save(); }}
Problem with template variables
There is one more problem with this approach.
The getSaveAction()
method is called in the context of the component.
If you want to use template variables in the getSaveAction()
method you cannot do this.
@for(todo of todos) { <div> <input type="checkbox" [checked]="todo.completed" /> <span>{{ todo.title }}</span> <button [appAsyncAction]="save$">Save</button> </div>}
As you can see we cannot use todo
variable with save$
variable.
To fix this problem we need to extends our AsyncActionDirective
directive with additional input.
type AsyncAction = Observable<unknown> | OperatorFunction<unknown, unknown>;
export class AsyncActionDirective { action = input.required<AsyncAction>({ alias: "appAsyncAction" }); context = input<unknown>(undefined, { alias: "appAsyncActionData" });
private readonly inProgress = signal(false); private readonly destroyRef = inject(DestroyRef);
handleClick() { if (this.inProgress()) { return; }
this.inProgress.set(true);
const action$ = this.prepareAction();
action$ .pipe( takeUntilDestroyed(this.destroyRef), finalize(() => this.inProgress.set(false)), ) .subscribe(); }
private prepareAction() { const action = this.action();
if (isObservable(action)) { return action; }
const context = this.context();
if (context === undefined) { throw new Error("You have provided a operator function but not provided a context"); }
return of(context).pipe(action); }}
Now we can update our component.
export class ToDoComponent { private readonly todoService = inject(TodoService);
save$ = pipe<ToDoItem, void>(switchMap(() => this.getSaveAction()));}
@for(todo of todos) {<div> <input type="checkbox" [checked]="todo.completed" /> <span>{{ todo.title }}</span> <button [appAsyncAction]="save$" [appAsyncActionData]="todo">Save</button></div>}