Skip to content

Angular: Reusable Confirm-Action-Dialog Directive

Published on June 16, 2022

Written by @rhagen_

Reading time 5 minutes

More on Angular

Including confirmation dialogs in an application is a common feature that gives users the chance for a second conscious decision whether to run a selected action or cancel. This can especially be useful in contexts where the action cannot be undone or a workflow is triggered that might run for a while and the user needs to be made aware of the implications.

While working on a current a project I noticed the increased usage of confirmation dialogs in various components and the repetition of code coming with it. A typical component would implement this feature like this:

<button (click)="doSomething()">Do something</button>

<ng-template #confirmationDialog>
<h1 mat-dialog-title>Confirm action</h1>
<div mat-dialog-content>Do you really want to perform this action?</div>
<div mat-dialog-actions>
<button mat-flat-button [mat-dialog-close]="false" cdkFocusInitial>
No
</button>
<button mat-flat-button color="warn" [mat-dialog-close]="true">
Yes
</button>
</div>
</ng-template>
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
export class AppComponent {
@ViewChild('confirmationDialog') confirmationDialog!: TemplateRef<any>;

constructor(private dialog: MatDialog) {}

doSomething(): void {
const dialogRef = this.dialog.open(this.confirmationDialog, {});

dialogRef.afterClosed().subscribe((confirmed) => {
if (confirmed) {
// Do something...
console.log('Action confirmed');
}
});
}
}

In my project every component used to define their version of the dialog template, injected MatDialog and implemented the logic for opening and closing the dialog. In order to reduce repetition we can create a directive that encapsulates the behavior of showing the confirmation dialog on click and running a select action only when the dialog was confirmed. As MatDialog expects a Component or a TemplateRef and a directive does not have a template we refactor the dialog template into a component first:

<h1 mat-dialog-title>Confirm action</h1>
<div mat-dialog-content>Do you really want to perform this action?</div>
<div mat-dialog-actions>
<button mat-flat-button [mat-dialog-close]="false" cdkFocusInitial>
No
</button>
<button mat-flat-button color="warn" [mat-dialog-close]="true">
Yes
</button>
</div>
@Component({
templateUrl: './confirm-action.component.html',
styleUrls: ['./confirm-action.component.scss'],
})
export class ConfirmActionComponent {}

The directive needs to register a click handler for the host element which in this case is accomplished by using HostListener. After clicking yes on the dialog the confirm output emits to signal the consumer. In case of clicking no nothing will be emitted.

@Directive({
selector: '[confirmAction]',
})
export class ConfirmActionDirective {
@Output() confirm = new EventEmitter();

@HostListener('click')
doConfirm() {
const dialogRef = this.dialog.open(ConfirmActionComponent, {});

dialogRef.afterClosed().subscribe((confirmed) => {
if (confirmed) {
this.confirm.emit();
}
});
}

constructor(private dialog: MatDialog) {}
}

And now all consumers with actions that require confirmation can be simplified to this:

  <button confirmAction (confirm)="doSomething()">
Do something
</button>
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
export class AppComponent {
constructor() {}

doSomething() {
// Do something...
console.log('Action confirmed');
}
}

The consuming components of this behavior now don't need to deal with the specifics of opening a dialog and handling the dialog result anymore. They do not have a dependency on MatDialog and they also don't have to define a template. This gives us the added benefit that we can more easily ensure a uniform confirmation dialog look and feel across the whole application.

Refactoring this behavior into a Directive is one of the possible solutions for the use case at hand and the declarative approach I wanted to go for in this example. Another one would be refactoring this behavior into a Service.

With the recent release of Angular 14 I learned about the inject() function and the idea of DI Functions and wondered how an implementation for the current use case would look like.

The catch here is that inject() can only be called in the constructor, a constructor parameter and a field initializer. To make it useful for what I had in mind we need to work around these restrictions by using closures. Here is what the confirm action dialog feature would look like using a DI function:

export const confirmAction = () => {
const dialog = inject(MatDialog);

function doConfirm(action: () => void) {
const dialogRef = dialog.open(ConfirmActionComponent, {});

dialogRef.afterClosed().subscribe((confirmed) => {
if (confirmed) {
action();
}
});
}

return doConfirm;
};
@Component({
selector: 'app-root',
template: `
<button (click)="doSomething()">Do something</button>
`
,
})
export class AppComponent {
doConfirm = confirmAction();

constructor() {}

doSomething() {
this.doConfirm(() => {
// Do something...
console.log('Action confirmed');
});
}
}

Instead of injecting a Service we have to initialize the closure on a field to be usable in other functions.