NgIf You Want To Learn Angular
Helping Angular Developers Avoid Bad Practices
It’s always fun to hate on Angular for being more opinionated and cumbersome than its modern web cousins React and Vue, but Angular can actually be extremely performant, maintainable, and highly scalable when written correctly. Additionally, its deep integration with RxJS and uncomplicated HttpClient makes Angular powerful yet approachable while being backed by one of the world’s largest tech companies, Google.
To help ease the learning curve, Angular provides a multitude of useful documentation for both newcomers and experienced individuals including a style guide, NgModule FAQ, cheatsheet, and more, but these can be too dry and daunting for all except the most intrepid among us. Therefore, I’ve compiled some of the most important patterns from the documentation and combined it with more than two years of practical experience on Angular projects ranging from a few to dozens of developers to produce some important best practices.
This article is primarily intended for those who already have a solid grasp of Angular, but want to ensure that they don’t propagate bad practices so they can write the best Angular possible. While there are too many topics to fully cover here, this article addresses some of the most useful and important ones: project structure, naming conventions, lifecycle hooks usage, template binding guidelines, and styling pitfalls.
Project Structure
Angular projects are composed of NgModules, which are “containers for a cohesive block of code dedicated to an application domain, a workflow, or a closely related set of capabilities.” NgModules are extremely important because they help separate concerns of the application into distinct sections and allow Webpack to lazy load modules for better performance if configured correctly, but these are unquestionably the most complicated aspect of Angular to understand.
Angular recommends using a project structure consisting of a root app module, shared module, and feature modules, which are outlined in the example below:
An additional module, the core module, is often added to encapsulate some of the core logic of the application similar to a shared module. Therefore, using a project structure with an app module, shared and core modules, and feature modules will allow the highest encapsulation and greatest performance.
App Module
The app module will declare and bootstrap the app component and import everything that needs to be available app-wide like the Router and Store. While there are various other types of modules, the app module is the only required module because it is used to bootstrap the application.
Shared Module
The shared module will expose components that are reusable and needed throughout the entire application. This will be a library of display components which carry no application state such as a button or accordion card component, but provide a solid foundation of reusable components.
There should be no providers in the shared module; if the provider is used in a lazy loaded module, then the child injector will create a new instance of the provider instead of using the app-wide singleton that Angular injected into one of the eagerly loaded components which breaks the singleton pattern for services.
After creating the shared module, it should then be imported into the feature modules and not the app module. Importing the shared module into a feature module doesn’t necessitate that the entirety of the shared module is included in the feature module; only what is needed from the shared module will be included with the feature. This helps when lazy loading to keep bundle sizes down and only send information that the feature needs.
Core Module
The core module contains components that are tied to the business logic of the application. Core components can and often do affect application state. For example, a logout button might be on every page and across domains, so implementing this as a stateless component can increase the work to maintain the logout button. Instead, this could be a stateful component that maintains the logout state and dispatches actions when clicked.
This module is an augmentation of the recommended module design by Angular. Therefore, there is no prescribed best practice when using a core module. That said, there are still a couple pitfalls to be vigilant about. If the core module is imported into lazy loaded feature modules like the shared module, it is still recommended to have no providers in the core module for the same reasons as the shared module. However, core modules will often contain critical components which will be used on every page, so importing it into the root module can be less overhead. In that case, using providers in the core module is perfectly acceptable because there is no risk of getting duplicate services.
Feature Modules
Feature modules are “modules you create around specific application business domains, user workflows, and utility collections.” These contain specific features and are often linked to domains of functionality in the application.
Angular calls out five types of feature modules: Domain Feature, Routed Feature, Routing, Service Feature, and Widget Feature modules. While most of these are self-explanatory, the only nuance is the difference between Domain and Routed Feature modules. Domain and Routed Feature modules are very similar, but the top level component in a Routed Feature module is the target of a route. Additionally, within a Routed Feature module, there may be different Domain Feature modules if there are multiple unique features within one route.
Therefore, the recommended project structure should look like:
app.module.ts
app.component.ts
app-routing.module.ts
modules
├── featureModule1
├── components
└── ...
├── featureModule2
└── featureModule3
shared
├── shared.module.ts
├── components
├── pipes
└── ...
core
├── core.module.ts
├── components
├── pipes
└── ...
Naming Conventions
Angular prescribes a few best practices when it comes to naming, and there are many additional practices you can choose to follow which can improve readability. However, the most important step to take when naming is to remain consistent within the codebase. As projects grow and teams wax and wane, consistent naming will lead to the least confusion among team members.
File Naming
For Angular feature files, the suggested naming schema is feature.type.ts
where type is the feature type, like component
or service
, and feature is a dash separated name. This allows for consistent naming among all the different types of features within the application and simple parsing to get files of specific types. Following this convention, the folder for an accordion card component would look like:
accordion-card/
├── accordion-card.component.html
├── accordion-card.component.scss
├── accordion-card.component.spec.ts
└── accordion-card.component.ts
Class & Selector Naming
Within each file, Angular only prescribes two patterns to follow: 1) the name of the class within the main component file should be suffixed with the feature type similar to the file name, and 2) the selector should be kebab-case using a custom prefix relevant to the feature. To illustrate these patterns, imagine creating a class for the accordion card component.
The Component
suffix for the class name provides further consistency between file names and the classes within and is a clear indicator that this is a component.
The prefix in the selector should succinctly identify “the feature area or the app itself”, according to the Angular style guide. This prefix will prevent collisions with other components, either native HTML or other custom components, and it will allow others to understand which feature area it comes from and its function from just the name. For example, if this component was specific to a display page for logged in users, the prefix might be user
. Alternatively, if this was a core component for an application named Wallet, the prefix could be wallet
.
Additional Naming
Outside of the aforementioned naming conventions, Angular provides very little additional direction for naming, though there are many patterns available to increase readability and maintainability. The most ubiquitous additional pattern is appending observables with a $
. While recognized by Angular, appending observables with a $
is not something found in most of Angular’s documentation. However, since observables are so critical to Angular, having a pattern for observables can increase readability and allow you to have a general sense of the type from a glance.
Beyond the most prevalent patterns discussed above, there are still additional steps using naming which can make code more readable. Similar to appending feature classes with the type like Component
, any custom interface or enum should be prefixed with I
or E
, respectively, so that the type can be understood from a glance. For example, if the accordion card needed an interface for data coming in and an enum for possible colors, then these could be named IAccordionCardData
and EAccordionCardColor
.
Finally, smart components can have many services injected into them including the store, router, and feature services. A pattern to identify these can help keep member variables separated from injected variables. One such pattern is to prepend injected variables with an underscore, so the declaration for an accordion card service in the constructor could look like constructor(_accordionCardService: AccordionCardService)
. This pattern harkens back to inbuilt AngularJS services which were prepended with a $
like $window
and $http
.
Lifecycle Hooks
Lifecycle hooks form the basis for data and local state management in components, and they are critical for having clean, maintainable projects. While there are many lifecycle hooks, there are a few that are crucial to understand fully: ngOnInit, ngOnDestroy, and ngOnChanges.
ngOnInit
The ngOnInit hook should be used to perform all of the initialization for the component. This involves setting up both subscriptions for the component and private variables to manage local state, and doing any work needed before passing inputs down to child components.
The only challenge with this hook is determining what should go in the ngOnInit hook vs. the class constructor. While setting up subscriptions and local state would work in either of these functions, the input bindings are undefined in the constructor and only set in the first change detection cycle. Therefore, any initialization logic that relies on inputs being set must be defined in the ngOnInit hook. In order to not split component initialization between the constructor and ngOnInit, the constructor’s purpose should therefore be for setting up dependency injection and calling any parent constructors whereas the ngOnInit hook should do component initialization.
In rare instances, the constructor will need to do initialization as well. For example, when reacting to route changes, subscribing to the route change observable in the constructor might be required because the events can be missed if subscribing in the ngOnInit.
ngOnDestroy
The ngOnDestroy should be used to do any necessary cleanup before the component and the references it held are destroyed. The most common use case for the ngOnDestroy is to unsubscribe from any observables so that memory leaks are avoided, but it is also often used to cancel any time driven events and reset any state related to that page so any subsequent navigation back will appear pristine.
ngOnChanges
The last prevalent lifecycle hook is the ngOnChanges. This hook will be called whenever Angular sets or resets any data-bound input properties. This function is perfect for logic that needs to run any time an input changes.
The only caveat when using this lifecycle hook is that it will be run before the ngOnInit. Therefore, the ngOnChanges logic needs to be independent from the initialization logic so that it doesn’t try to access properties that haven’t been initialized yet.
Finally, while this does seem like the silver bullet for any logic around changing inputs, the ngOnChanges will need a variety of conditionals in it so that it only fires when specific inputs are updated. By using setters, the update logic can be kept adjacent to the input and without any branching code, which can make it easier to maintain in the long run.
Templates
Because templates are mostly HTML, not a lot of thought usually goes into their performance. Angular, however, will review the templates during each change detection cycle to determine if there are any changes which should be propagated to the DOM. Therefore, any directives or interpolated expressions in the template should be as optimized as possible to help Angular keep the change detection cycle fast.
Functions In Templates
One of the most important tips to remember when creating Angular templates is to avoid calling functions from within the template because it will be reevaluated every change detection cycle. Suppose a floating point number is passed into a component, but needs to be shown in USD format (with a $
prepended and only two digits after the decimal point). A simple way to do this would be to write a function and use that in the template to transform the number to the formatted string.
However, functions that are called from the template are reevaluated in every change detection cycle. While Angular doesn’t view functions in a template as bad practice according to the Template Syntax doc, this can lead to many unnecessary and expensive calculations. There are two primary ways to fix this issue: move the formatting function to a pure pipe, or do the formatting before displaying on the template, like in the ngOnChanges or a setter.
Pipes
Angular’s pipes can be one of two types: pure and impure. Pure pipes will only be executed when “it detects a pure change to the input value” while impure pipes will be executed “during every component change detection cycle” according to the pipe doc, similar to a normal function with the same pitfalls. While there are nuances to what a pure change is, the primary takeaway is that Angular is able to avoid running the pipe again if the inputs haven’t changed which can greatly speed up the change detection cycle.
Pipes are great for situations where an idempotent function, one which has no additional effect if it’s called more than once with the same inputs, is needed throughout the application. However, when the function will only be used in one location, creating a pipe can be excessive.
Setters & ngOnChanges
Luckily, setters and ngOnChanges can solve this plight. The setter will only be called when the input changes, or implementing ngOnChanges with conditionals can be used to call specific code only when certain inputs are updated. Using a setter over ngOnChanges may be preferred because it is isolated to only the input which changed and doesn’t require conditionals to call the relevant code.
ngFor’s trackBy
When using the NgForOf directive, commonly used in the shorthand form as *ngFor, Angular will typically remove all the DOM elements associated with the data and then recreate the nodes whenever the value within the iterable changes, even if the underlying data is the same.
Angular can avoid having to tear down and recreate the DOM elements by leveraging a trackBy
function which tracks each individual element of the provided array. This trackBy
function, which should return a unique identifier for the data like an Id, is called to identify the data and see if it was an already generated node. If it is provided, Angular can reuse and potentially reorder the existing DOM elements instead of having to destroy and recreate the array of DOM nodes. While this isn’t extremely important for smaller lists or areas where the data will change infrequently, it can make be a considerable improvement over the default tracking in cases where the list changes often or the data is very large.
Styling
Regardless of what styling syntax is used, be it SCSS, LESS, or vanilla CSS, Angular will scope the styles to just the component that owns the style sheet by default. Therefore, using a combination of global styles like typography and colors with specific component styles can enhance maintainability and updateability of global styles while keeping component styles relevant to only the component’s concerns.
::ng-deep
The majority of styling problems in Angular relate to using the ::ng-deep
pseudo-class. Use of this pseudo-class will completely disable view-encapsulation for the rule it’s applied to, so that the style is allowed to apply globally. This is very helpful for styling external component libraries which don’t natively support your desired styles. For example, a style with selectors ::ng-deep .my-class
will target every DOM element using my-class
regardless of whether they are in the same component or not. Unfortunately, this can also lead to some very unusual results, especially with lazy loaded modules. If there is a deep style like the one above in a lazy loaded module and a tag with the same class .my-class
in another module, the styles will be different depending on if the lazy loaded module has been loaded bringing this global style with it.
To fix this, Angular recommends wrapping each instance of ::ng-deep
in a :host
selector so that the deep styles will be limited to the scope below the host component. The one caveat is that is that these styles will also apply to any children of the component with .my-class
in their templates.
:host ::ng-deep .my-class {
//styles here
}
While the shadow-piercing descendant operator is deprecated and Angular plans to drop support for it, there are still instances where using the operator is required (or substantially easier than alternatives). For example, if an application pulls in a component library where the color of the button can only be a couple colors internally, the ::ng-deep
pseudo-class can be used to color that button from outside of the button component. Therefore, although the operator is deprecated, its use should not be prohibited, only avoided.
Global Styles
Finally, within any web application, there should be consistent styles application-wide for a uniform user experience. To consolidate all of these in a common place, a global styles.scss
file at the top level of the application should be used, which imports the styles that make up the global set from sub-files such as typography.scss
and modals.scss
. The imported styles should be those that are structural or base styles which apply throughout the application. The classes and styling should then be leveraged throughout the application, keeping the global styles centralized and easily maintainable, and taking advantage of the DRY methodology.
Conclusion
Because of Angular’s modular and prescriptive design, there are many best practices to follow. Having a proper project structure and module design with an app module, shared and core modules, and feature modules improves performance and modularity within the project. Using Angular’s prescribed naming conventions and remaining consistent within code bases enhances intra-project and inter-project readability. Properly employing lifecycle hooks, especially ngOnInit, ngOnDestroy, and ngOnDestroy, ensures that components are easily maintainable. Finally, correctly scoping deep styles and limiting intensive functions from running every change detection cycle drastically improves performance.
While there are still many avenues beyond these which can improve and further optimize Angular projects, these techniques form a solid foundation for more readable, maintainable, and performant code, and can easily be implemented on a rolling basis moving toward better Angular.