State

Routed Angular dialogs

This article should be a rather short one. It’s about how we can create dialogs in Angular. Using dialogs in Angular can be tackled in complex ways but can be easy as well. When I started out with Angular I created this article. This is already 6 years ago at the time of writing this, and I believe

Routed Angular dialogs — State article by brechtbilliet on Angular In Depth
Routed Angular dialogs — State article by brechtbilliet on Angular In Depth
On this page · 7 sections

This piece is going to be a quick one. It covers how to build dialogs in Angular. Handling dialogs can get complicated, but it doesn't have to be. When I first started with Angular, I wrote this article. That was six years ago as of now, and I think there are better options available today. Actually, there already were back then.

The dialog itself

A dialog can be as simple as a <div> with position:fixed that holds a title and a body. Content projection is a suitable way to pass those in. Using just @Input() properties might not be sufficient here. We could also reach for the <dialog> html element, but for the sake of keeping things simple, I'm not using it in this article. The most basic implementation is shown here:

@Component({
  selector: 'my-dialog',
  template: `
  <h1 class="header">
    <ng-content select="[my-dialog-header]"></ng-content>
  </h1>
  <div class="body">
    <ng-content select="[my-dialog-body]"></ng-content>
  </div>

  `,
  styles: [
    `
    :host {
      width: 400px;
      height: 400px;
      background: #ccc;
      display: flex;
      flex-direction: column;
      opacity: 0.9;
      position: fixed;
      left: 50%;
      padding: 8px;
      top: 50%;
      transform: translate(-50%, -50%);
    }
    `,
  ],
})
export class MyDialogComponent {}

The my-dialog-header selector is used to project the header into the component, and the my-dialog-body selector handles the body projection.

The way to consume it is shown below. I won't add much commentary since it should be self-evident:

@Component({
  selector: 'app',
  template: `
  <my-dialog>
    <ng-container my-dialog-header>Hi there!</ng-container>
    <ng-container my-dialog-body>What's up?!</ng-container>
  </my-dialog>
  <p>
    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce gravida
   ...
  </p>

  `,
})
export class AppComponent {}

That was straightforward; we have a dialog that is always visible. In most cases, that's not what we want. We need dialogs to appear conditionally when the user triggers an action. There are several ways to manage the presence and visibility of these dialogs.

I'm not a big fan of using libraries for that purpose, except for the Angular Material CDK, because approach 3 is far simpler.

Approach one: the dialog service

With this approach, a service manages the creation and removal of dialogs. We could use the method from my old article, but the Angular Material CDK would be a better foundation. I won't dive into the details here as it falls outside this article's scope, but the developer takes responsibility for the component's lifecycle. They must manually create and destroy the dialog each time.

For confirmation dialogs, this method is fine, but for more complex ones, it's better to have something that manages the lifecycle automatically.

Advantages:

  • Injecting a service that handles dialog creation and cleanup is simple. However, the main scenario I see it for is confirmation dialogs. They tend to have a uniform api, with no inputs and always a confirm and cancel button.

Disadvantages:

  • Dependency injection is something we manage ourselves.
  • Feeding inputs and outputs is not straightforward.
  • It adds overall complexity.
  • Much bookkeeping is required just to display a simple dialog.

Use case:

  • Confirmation dialogs.

Approach two: The *ngIf statement

We can rely on *ngIf statements in the template to dictate whether dialogs render or not. The syntax is straightforward:

<my-user-detail-dialog *ngIf="showUserDialog"></my-user-detail-dialog>

When the showUserDialog property is set to true, the component renders and the dialog appears. Once it changes back to false, the dialog disappears.

Advantages:

  • No worries about the component's lifecycle. When the dialog is destroyed, there's no concern about memory leaks.
  • It's a straightforward method.

Disadvantages:

  • It doesn't scale well. Think about having numerous *ngIf statements scattered around solely to control dialog visibility.
  • What if a <user-row> component contains a dropdown with ten actions, each action leading to a distinct dialog? That would mean ten different *ngIf statements. That's a scalability problem.

Use case:

  • When there is only a single dialog to display, and you prefer not to tie it to a route.

Approach 3: Routed dialogs

A well-established practice in web development is ensuring that when a user refreshes the page, they return to the exact same state as before. They shouldn't be bounced back to the home page but should remain in the precise spot. At the very least, that provides the optimal user experience.

Consider this scenario: We have a page listing users in a table. Clicking on a user opens a view with that user's detailed information. Whether that detail appears in a dialog or on a separate page is not the core point. The route user/:userId should load the <user-detail> component. Right now, we think of it as a distinct page, but product management has just decided that <user-detail> is not a page but a dialog. That means we'd have a <user-detail-dialog> component.

This approach is surprisingly simple. We build a <user-detail-dialog> that wraps our earlier my-dialog component. We attach it to the user/:userId route using a child router-outlet, and it all works.

Very little code is needed to get this working:

Our app component simply holds a router-outlet. This is where the top-level routing config components get rendered.

// app.component.ts
@Component({
  selector: 'app',
  template: `
  <router-outlet></router-outlet>
  `,
})
export class AppComponent {}

We've created a <users> component that loads some mock user data and presents it in a table. This gets rendered inside the previously mentioned router-outlet. It also offers a detail link for each user, navigating to users/:userId. A crucial detail: below the table there is another child router-outlet. This is where the dialog will be rendered.

@Component({
  selector: 'users',
  template: `
  <table>
    <tbody>
      <tr *ngFor="let user of users$|async">
        <td></td>
        <td></td>
        <td>
          <a routerLink="">Detail</a>
        </td>
      </tr>
    </tbody>
  </table>
  <router-outlet></router-outlet>
  `,
})
export class UsersComponent {
  users$ = this.usersService.getUsers();
  constructor(private usersService: UsersService) {}
}

We've developed a <users-detail> component that leverages our previous <my-dialog> component to show user details. Based on the :userId param, it utilizes the UsersService to fetch the data and display it through content projection.

@Component({
  selector: 'users-detail',
  template: `
  <my-dialog *ngIf="user$|async as user">
    <ng-container my-dialog-header>Details of  </ng-container>
    <ng-container my-dialog-body>Role: </ng-container>
  </my-dialog>
  `,
})
export class UsersDetailComponent {
  private userId$ = this.activatedRoute.params.pipe(map(p => p.userId));
  user$ = this.userId$.pipe(
    switchMap(id => this.usersService.getById(id))
  )
  constructor(
    private activatedRoute: ActivatedRoute,
    private usersService: UsersService
  ) {}
}

In the module below, we see the routing configuration. The key point is that the UsersDetailComponent class is included in the children of the UsersComponent class. A close look at this config reveals we're dealing with two nested router-outlets.

@NgModule({
  imports: [
    ...
    RouterModule.forRoot([
      {
        path: '',
        redirectTo: 'users',
        pathMatch: 'full',
      },
      {
        path: 'users',
        component: UsersComponent,
        children: [
          {
            path: ':userId',
            component: UsersDetailComponent,
          },
        ],
      },
    ]),
  ],
  ...
})
export class AppModule {}

That's all there is to it. If product management later decides the user details shouldn't be in a dialog but on a full page, the transition requires minimal effort. We can refresh the page at any time, and we don't have to fret about dialog lifecycle management.

Advantages:

  • The dialog can be bookmarked.
  • The URL can be shared with colleagues.
  • The browser's previous and back buttons work as expected.
  • Guards can be used to prevent navigation away from the dialog (for instance, if there's a dirty form inside).
  • No need to worry about memory leaks. It shouldn't even matter that the user detail info is displayed within a dialog.

Disadvantages:

  • Not suitable for confirmation dialogs (we don't want confirm routes scattered everywhere).

Use case:

  • I'd use this for all dialogs that aren't generic.

Angular CDK

The Angular CDK could be used to refine everything with position strategies and other features, but that could be the subject of a future article.

Conclusion

Embracing state in routes brings a host of advantages. Using that state can determine dialog visibility. Whether a view appears in a dialog or on a separate page shouldn't dictate the routing configuration.

The demo is available here

Reviewers

Thanks to the excellent reviewers:

B
brechtbilliet

Writes about RxJS, Components, State. Active 2016–2022.

All 22 articles →