Angular: extract a module with existing components and important lesson about NgModule

Angular: extract a module with existing components and important lesson about NgModule

I often see Angular applications with bad architecture, especially no separation on modules. As a result, all components of an application are put in a single app/components folder which makes it hard to separate concerns. Which components are grouped together? Which components require a service? Which component is merely a part of another component? Are there any dumb components?

In this article, I’ll show how easy it is to refactor an Angular application and extract a new module with existing components. I’ll describe a simple case, with no global services.

Initial state

I had a simple Angular 8 application with:

  • all components, including HomeComponent and AppComponent, in app/components
  • AppComponent is just the router: <router-outlet></router-outlet>
  • HomeComponent is the entry component for the router and displays all other components
  • there is only one module: AppModule which imports the Angular Material and all my components

Move files

I started with creating a subdirectory app/course-editor and moving all the components except for AppComponent to app/course-editor/components. I also moved the corresponding model classes from app/model to app/course-editor/model. Thus I had all related files in a separate folder.

VS Code automatically updated all (almost all) references while moving, so it was quick. Git commit.

Create Angular module

Now that files are in a subfolder, I encapsulated them in a new Angular module.

I created CourseEditorModule in app/course-editor/course-editor.module.ts with:

  • all components from app/course-editor/components in the declarations section
  • HomeComponent in the declarations and exports section

Information

A short reminder about what are the Angular module sections for:

  • declarations – list all components that will be used by components using this module, including the ones listed in this list
  • imports – list all modules that are included altogether, e.g. Material libraries or other modules from my application
  • exports – list all components that will be available to other modules that import this module
  • providers – list all services defined in this module, though @Injectable({ providedIn: foo }) is the preferred way
  • bootstrap – list the component that is the root for the application. There will be only one bootstrap section with one component in a typical applications, sometimes there can be more modules with other bootstraps, e.g. in case of debug-time client-side rendering and production server-side rendering

And here the complex part begins. The application is not going to compile soon.

I removed all the components that I moved to the new module, from AppModule.

Create new main component in new module

It was not necessary in this case, as there was already a main component – HomeComponent. Otherwise, I’d just create a new component by extracting the relevant parts from HomeComponent, add it in app/course-editor/components and declare and export it in CourseEditorModule.

Extract a Shared Module

Usually, there are parts that are used in both AppModule and CourseEditorModule. In my case it was the Angular Material library modules, like BrowserModule, MatButtonModule, and so on. Sometimes it is tricky to refactor the existing modules such that you don’t introduce a circular dependency, e.g. AppModule imports CourseEditorModule because it needs to access the HomeComponent, but CourseEditorModule needs to import AppModule, because it provides MatButtonModule. Even worse things happen to services, which may stop being singletons…

I extracted all Angular Material modules and all my “utilities” used (possibly) everywhere in the application from AppModule to SharedModule in app/shared.module.ts.

Advice

Avoid naming a module CommonModule. There is already one in Angular, frequently used. More about it in the last chapter.

Note that previously in AppModule I had (after removing all course-editor components):

TypeScript
app/app.module.ts

I imported e.g. MatButtonModule and it (with its Material Button, i.a. mat-button directive) was available to all the components declared here. So AppComponent could use the Material Button.

When I extract the common libraries to a new module, it is not enough to import them. Actually, it is not necessary if there are no new declared components. Instead, I have to export them and make them available to components declared in the modules that import SharedModule.

However, if there were new components declared in SharedModule, I would also have to import the modules that are accessed by these components.

See the example of my case:

TypeScript
app/shared.module.ts

Differentiate between two kinds of imports, though. import { MatButtonModule } from '@angular/material/button' is a language syntax to load MatButtonModule. However, @NgModule({ imports: [ MatButtonModule ] }) is a framework construct to make MatButtonModule available to all components listed in the declarations section. Since there was no component using anything from Angular Material, I could skip the imports section.

After refactoring, AppModule looks now as follows:

TypeScript
app/app.module.ts

Finally, CourseEditorModule also imports SharedModule in its imports section.

Note that I avoided importing CourseEditorModule. It is automatically instantiated by the routing mechanism:

TypeScript

Well done. The application works again (just remember to relaunch it).

CommonModule

All Angular applications require CommonModule, which provides directives like ngIf or ngFor. If you get the error:

ERROR in Can't bind to 'ngIf' since it isn't a known property of 'div'. ("<div id="error" [ERROR ->]*ngIf="modelService.error.getValue()">
        <mat-icon>error</mat-icon>
        Error when {{ modelService.erro")
Property binding ngIf not used by any directive on an embedded template. Make sure that the property name is spelled correctly and all directives are listed in the "@NgModule.declarations". ("[ERROR ->]<div id="error" *ngIf="modelService.error.getValue()">
        <mat-icon>error</mat-icon>
        Error when {{ m")
Can't bind to 'ngIf' since it isn't a known property of 'app-category-editor'.
1. If 'app-category-editor' is an Angular component and it has 'ngIf' input, then verify that it is part of this module.
2. If 'app-category-editor' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.
3. To allow any property add 'NO_ERRORS_SCHEMA' to the '@NgModule.schemas' of this component. ("

        <app-category-editor fxFlex="1 0 1"
                [ERROR ->]*ngIf="!!modelService.getSelectedTopic()"
        ></app-category-editor>

")
Property binding ngIf not used by any directive on an embedded template. Make sure that the property name is spelled correctly and all directives are listed in the "@NgModule.declarations". ("
        ></app-categories-editor>

        [ERROR ->]<app-category-editor fxFlex="1 0 1"
                *ngIf="!!modelService.getSelectedTopic()"
        ></app-category-ed")

or

ERROR in Can't bind to 'ngForOf' since it isn't a known property of 'button'. ("
<mat-action-list cdkDropList (cdkDropListDropped)="drop($event)">
        <button mat-list-item
                [ERROR ->]*ngFor="let topic of topics"
                (click)="selected(topic)"

then it means that app-category-editor component is declared in a module that does not import CommonModule.

Note that CommonModule is re-exported by BrowserModule and BrowserAnimationsModule. So importing any of these three will be enough.

Leave a Reply

avatar
  Subscribe  
Notify of