DEV Community

Cover image for Angular Architecture: Lazy loaded dialogs
Maciej Sawicki
Maciej Sawicki

Posted on

Angular Architecture: Lazy loaded dialogs

In the previous article I wrote about storing context menu action definitions in a scalable way using a Command Pattern (no more 1000 lines ActionService 🎉). In the showcase application I used dialogs that were loaded eagerly, which means all the dialog modules are being downloaded to the browser at once despite displaying only some of them most of the time. Let's fix it!

After this article ends you will have known how to easily manage dozens of dialogs that are scalable and independent of each other and the rest of the app.

A gif from the showcase application presenting dialogs sequencing:
unassign action flow

Link to the article:

The Problem

Currently, the dialogs structure looks like this:

current dialogs structure in the project

Each dialog:

  1. Has a module,
  2. Has a component,
  3. Has a data input model structure.

So far so good. We show a dialog using Angular Material's MatDialog Service:

constructor(private matDialog: MatDialog) {
   const dialogData: JobUserAssignDialogDataModel = {
         jobId: params.jobId,
   };

   this.matDialog
       .open(JobUserAssignDialogComponent, {data: dialogData})
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, we have 2 problems with this approach:

1. All dialogs are bundled with AppModule

In order for Angular to instantiate the JobUserAssignDialogComponent it needs to be declared somewhere and since it was declared in the JobUserAssignDialogModule we also need to import it in the AppModule:

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...,
    JobUserAssignDialogModule,
    ConfirmationDialogModule
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

Now all the dialogs are bundled with main.js file, which results in downloading them all even if a user is on the page that doesn't use any of them at all!

2. No static type enforcement for dialog data

In the expression .open(JobUserAssignDialogComponent, {data: dialogData}) the data field has type of any until we manually specify given type. This is very error prone, because it's extremely easy to mistype something or just not type something at all.

We need a method, which automatically matches dialog data interface with the dialog.

The Solution

Just like in Action Definition's case from the previous article it would be wise to have a Dialog Service per each dialog module, so that we won't end up with one gigantic service.

First let's create a base class for a Dialog Service:

@Directive()
// tslint:disable-next-line:directive-class-suffix
export abstract class AsyncDialog<ComponentType, DataType, ReturnType = unknown> {
  constructor(protected matDialog: MatDialog) {
  }

  abstract async open(data: DataType): Promise<MatDialogRef<ComponentType, ReturnType>>;
}
Enter fullscreen mode Exit fullscreen mode

This class is mostly for typing consistency.

The AsyncDialog class consumes 3 generic parameters:

  1. ComponentType - a dialog class.
  2. DataType - a structure a dialog receives from its opener.
  3. ReturnType - a structure a dialog returns to its opener.

We also have to annotate it with @Directive(), because since version 10 Angular removes all types from constructor parameters of a class without a decorator and we need to inject MatDialog.

There is also 1 abstract method:

  1. open() - template for opening a dialog

After creating a base class we can create a JobUserAssignDialogService:

@Injectable({providedIn: 'root'})
export class JobUserAssignDialogService extends AsyncDialog<JobUserAssignDialogComponent, JobUserAssignDialogDataModel, UserModel> {

  async open(data: JobUserAssignDialogDataModel): Promise<MatDialogRef<JobUserAssignDialogComponent, UserModel>> {
    // the magical part of importing a module asynchronously
    await import('../job-user-assign-dialog.module');

    return this.matDialog.open(JobUserAssignDialogComponent, {data});
  }
}
Enter fullscreen mode Exit fullscreen mode

The JobUserAssignDialogService inherits from AsyncDialog base class created before. In the 3 generic parameters we specify what component this service is responsible for, what it consumes and what it returns.

The magical part is await import('...'). This tells angular to fetch the module file if it has not yet been downloaded. Then after that we just open the dialog like we did before.

Note: We use async keyword, because asynchronous importing returns promise and we need to wait with opening the dialog until the operation completes.

The example below shows that a module file is fetched only when we click the action.
opening async dialog fetches its module file

Unexpected problem

The gif shows that the dialog module is being fetched asynchronously, however, if we run ng build and take a closer look at the source code of the JobListViewComponent we can find JobUserAssignDialogComponent bundled with the app instead of on the lazy loded module.

potentially lazy loaded dialog is being bundled with the view

WAIT WHAT?

Shouldn't the dialog component be lazy loaded? We have just downloaded the module lazily! What is going on!

...

It turns out the line responsible for this behaviour is the one which opens the dialog. But why?

@Injectable({providedIn: 'root'})
export class JobUserAssignDialogService extends AsyncDialog<...> {

  async open(data: JobUserAssignDialogDataModel): Promise<...> {
    await import('../job-user-assign-dialog.module');

    // THIS IS THE CULPRIT!
    return this.matDialog.open(JobUserAssignDialogComponent, {data});
  }
}
Enter fullscreen mode Exit fullscreen mode

Dependency graph

If we analyze the import chaining (what imports what), we can draw the following graph:

dialog dependency graph

From view Jobs view, where we invoke the action, to the dialog there are 3 imports along the way:

  1. JobsComponent injects JobAssignAction and uses the type as value (Angular preserves a type of constructor params, so that it knows what to inject).
  2. JobAssignAction injects JobUserAssignDialogService and uses the type as value.
  3. JobUserAssignDialogService opens the JobUserAssignDialogComponent and uses its type as value.

So in order for JobsComponent to be run by Angular it needs to have a dependency of JobUserAssignDialogComponent, because all along the way it was used as a value!!!

So how can we fix it?

Using as value vs type

First we need to understand some differences between value and a type:

As a value

In the example below we use a class as a value, which means it is still there after compiling to JavaScript.

// typescript
const componentClass = JobUserAssignDialogComponent;

// javascript
const componentClass = JobUserAssignDialogComponent;
Enter fullscreen mode Exit fullscreen mode

As a type

In the example below we use a class as a type, which means it is completely removed from JavaScript with all of its imports.

// typescript
const component: JobUserAssignDialogComponent = {};

// javascript
const component = {};
Enter fullscreen mode Exit fullscreen mode

Lazy loading a component

Now we somehow need to use JobUserAssignDialogComponent as a value, but without importing it as a value. We can ask the module that we lazily imported for some help!

@NgModule({
  declarations: [JobUserAssignDialogComponent],
  imports: [...],
})
export class JobUserAssignDialogModule {
  static getComponent(): typeof JobUserAssignDialogComponent {
    return JobUserAssignDialogComponent;
  }
}
Enter fullscreen mode Exit fullscreen mode

It's now the module's responsibility to import the component as a value, by having a static method called getComponent(), and returning it to us.

After that we can adjust the dialog service.

@Injectable({providedIn: 'root'})
export class JobUserAssignDialogService extends AsyncDialog<JobUserAssignDialogComponent,...> {

  async open(data: JobUserAssignDialogDataModel): Promise<MatDialogRef<...> {
    const importedModuleFile = await import('../job-user-assign-dialog.module');

    // the `importedModuleFile` has all the imports of the file
    // and since one of them is `JobUserAssignDialogModule`
    // we can use it to invoke previously defined `getComponent()` method
    return this.matDialog.open(
      importedModuleFile.JobUserAssignDialogModule.getComponent(),
      {data},
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Et voilà! The dialog component is now bundled with the module, which is being loaded lazily.

What about component class used as a generic parameter AsyncDialog<JobUserAssignDialogComponent,...>? Fortunately, it is used as a type so it is completely removed in the compilation phase.

Final project structure

The scructure now looks like this:
async dialog project structure

Each dialog:

  1. Has a dedicated module,
  2. Has a dedicated component,
  3. Has a dedicated data input structure,
  4. Has a dedicated async service.

If we now add 50 more dialogs to our app and use them all in one view none of them would come immediately. It is handy in case some of them uses 2MB library that plays a violin while mining bitcoins at the same time. The view would be slim and light until we trigger the right action which in result would download it all.

Don't forget to remove Dialog Module imports from your AppModule

The GitHub repo can be found below as well as a StackBlitz demo:

GitHub logo Humberd / lazy-loaded-dialogs-in-angular

Showcase of lazy loaded Angular dialogs

Conclusion

During the last 5 minutes we've learned why loading all dialogs at once is not optimal. We discovered the problem why dialogs are bundled with main.js file. We've also understood the difference between using a class as a value and using it as a type. At the very end we implemented a solution for all dialogs to by asynchronous by lazy loading their module classes and using them as a proxy to get a component class reference, so that Angular can instantiate it.

I hope you are impressed as much as I was when I discovered it was possible :)

In the next article I will write something about Angular. Probably.

See you around.

Top comments (0)