Angular Best Practices and Core Concepts - 2022
Disclaimer: Here our Angular team has compiled list of best practices they observed while working in Angular for last 4 years. The source of some of these observations and practices is also listed in other blog posts and they are listed at the end of this.
- Single Responsibility Principle
- Angular Coding Style Guide
- Naming Conventions and Folder Structure
- File Naming
- Class Naming
- Folder Structure
- Root Module
- Feature Module
- Shared Module
- Core Module
- Paths in tsconfig
- Make use of Angular CLI
- Proper utilization of Lazy Loading
- Utilizing trackBy alongside ngFor
- Index.ts Usage
- Smart Components vs Presentational Components
- Properly unsubscribe from observables
- Use of async pipe
- Using take(1)
- Using takeUntil
- Avoid using functions in template rendering
- Dealing with ExpressionChangedAfterItHasBeenChecked Error
- RxJS and Reactive Programming
1. Single Responsibility Principle
Each file should only have one component, directive, service, etc. defined inside of it and should be named as such, e.g. home.component.ts, home.service.ts.
2. Angular Coding Style Guide
- Rather than having a lengthy and meticulous list of formatting guidelines, just use Prettier to handle formatting, spacing, and module sorting, preferably with a precommit hook to ensure this happens automatically before committing code to source control.
- If the variables have constant values, declare them with const, otherwise declare with let. Avoid using var.
- Don't name the interfaces with starting uppercase I as you would do in some programming languages.
3. Naming Conventions and Folder Structure
3.1 File Naming
Files should use kebab-case and should include their purpose or type, e.g. home-routing.module.ts, home.component.ts, home.component.scss, home.module.ts.
3.2 Class Naming
When naming your classes, use the TitleCase style with the added suffix representing your file type TcHomeComponent, AuthService.
Everything else, such as methods and variables, should use camelCase. Private methods and variables can be prefixed with an underscore, e.g. _privateVariable.
3.3 Folder Structure
|-- app app.component.ts app-routing.module.ts app.module.ts |-- pages |-- home |-- [+] components |-- home.component.ts |-- home-routing.module.ts |-- home.module.ts |-- core |-- [+] authentication |-- [+] guards |-- [+] interceptors |-- [+] services |-- core.module.ts |-- ensureModuleLoadedOnceGuard.ts | |-- shared |-- [+] components |-- [+] directives |-- [+] pipes |-- [+] models shared.module.ts | |-- [+] configs |-- assets |-- scss |-- [+] partials |-- _base.scss |-- styles.scss
One of the most important things to know about Angular is that it utilizes Angular Modules to tightly group related features. Using Angular modules is an Angular folder structure best practice. Each module should have its folder, and it should be named according to its Module Name. Angular does not distinguish between the different Modules, but we should classify our modules for better understanding.
3.3.1 Root Module
Root module is needed for starting an Angular app. This module loads all the root components and other modules. Root Modules are alternatively known as AppModule and are created under the /src/app folder.
3.3.2 Feature Module
Feature module, as the name suggests, implements a specific feature of your Angular app. All the associated pipes, components, and directives specific to that feature are included in its module. Typically discrete screens can be thought of as individual features, but other logical groupings are valid as well.
3.3.3 Shared Module
Next on the list in shared modules. Creating shared modules in your Angular project helps you to organize and streamline your code. You can gather all the commonly used components, pipes, and directives into one module and then import the entire module whenever needed in any part of your application.
3.3.4 Core Module
Core module is a module created to define the core services of your app. The core module has somewhat diminished in usefulness since it has become standard to include @Injectable({providedIn: 'root'}) at the top of every service, which automatically provides each service in root as a singleton. However, it still makes sense for some import once classes to live here, like interceptors, but everything in here could just as easily be grouped into the shared folder section, just make sure to only provide your interceptors in the AppModule.
3.4 Paths in tsconfig
Another way to help with organizing your project and simplifying import paths is making use of the paths section in tsconfig. Here is an example tsconfig with some sections omitted:
{ "extends": "./tsconfig.json", "compilerOptions": { "paths": { "@shared/*": ["src/app/shared/*"], "@core": ["src/app/core/*"], "@ui": ["../../libs/ui/src/index.ts"] } } }
Now when referencing the shared and core folders, we can use some small absolute paths. This is especially useful when referencing shared libraries in an Nx monorepo:
Without:
import {LessonsService} from "../../core/services/lessons.service"; import {Lesson} from "../../shared/model/lesson"; import {UiModule} from "../../../../../../libs/ui/src/index.ts";
With:
import {LessonsService} from "@core/services/lessons.service"; import {Lesson} from "@shared/model/lesson"; import {UiModule} from "@ui";
4. Make use of Angular CLI
# Install Angular CLI npm install -g @angular/CLI # Check Angular CLI version ng version
Angular CLI is a command-line interface tool used for initializing, developing, scaffolding, maintaining, testing, and debugging Angular apps. You should use Angular CLI to build an initial-level structure for your overall application.
**Here are some of the essential commands to use with Angular CLI **
- ng new create an app that already works, out of the box.
- ng generate used for generating components, services, routes, and pipes with test shells.
- ng serve helps to test your app locally when developing.
- ng test for running various Angular tests on your app.
- ng add @angular/PWA helps in setting up the Angular service worker.
As a more extensible alternative, check out Nx
5. Proper utilization of Lazy Loading
Lazy loading can drastically reduce the file size of your initially served application by delaying the loading of modules until they're specifically requested. This will help with website loading times, which is important for SEO.
Routing without lazy loading:
const routes: Routes = [ { path: 'dashboard', component: DashboardComponent }, ];
Routing with lazy loading (Angular 14):
const routes: Routes = [ { path: 'dashboard', loadChildren: () => import(`./dashboard/dashboard.module`).then( module => module.DashboardModule ) }, ];
This requires the DashboardComponent to be a part of a module DashboardModule with its own DashboardRoutingModule in order to work.
6. Utilizing trackBy alongside ngFor
ngFor can be used to render a set of components for each item in an array. When the underlying list or any of its items are changed, the entire component list will be re-rendered. trackBy can be used to make it so only the components whose underlying items had changed will be re-rendered, improving performance.
Without trackBy
<ul> <li *ngFor="let item of collection;">{{item.id}}</li> </ul>
With trackBy
@Component({ selector: 'my-app', template: ` <ul> <li *ngFor="let item of collection;trackBy: trackByFn">{{item.id}}</li> </ul> <button (click)="getMoreItems()">Add Items</button> `, }) export class App { currentId = 3; collection = [{id: 1}, {id: 2}, {id: 3}]; getMoreItems() { this.currentId++; this.collection = this.collection.push({id: currentId}); } trackByFn(index, item) { return index; // or item.id } }
7. Index.ts Usage
Index.ts can be a useful tool for bundling related classes together. I find this most useful when building libraries or when using a a feature-based organization structure.
For example, we have /heroes/index.ts as
//heroes/index.ts export * from './hero.model'; export * from './hero.service'; export { HeroComponent } from './hero.component';
We can import everything by using source folder name
import { Hero, HeroService } from '@shared/heroes'; // index is implied
8. Smart Components vs Presentational Components
I tend to think about the categories of where business logic can go in an Angular app in this way: - Application-level or screen-level components - Components used within a screen (often shared) - Services
In general, it is often best to keep components within a screen as pure presentational components, allowing the containing component to handle the logic and behavior via inputs/outputs. Business logic that is used between multiple application-level components should go, and often needs to go in a service. Business logic that is specific to one screen is ok to put directly in the screen component.
An example presentational component:
import {Component, OnInit, Input, EventEmitter, Output} from '@angular/core'; import {Lesson} from "../shared/model/lesson"; @Component({ selector: 'lessons-list', template: ` <table class="table lessons-list card card-strong"> <tbody> <tr *ngFor="let lesson of lessons" (click)="selectLesson(lesson)"> <td class="lesson-title"> {{lesson.description}} </td> <td class="duration"> <i class="md-icon duration-icon">access_time</i> <span>{{lesson.duration}}</span> </td> </tr> </tbody> </table> `, styleUrls: ['./lessons-list.component.css'] }) export class LessonsListComponent { @Input() lessons: Lesson[]; @Output('lesson') lessonEmitter = new EventEmitter<Lesson>(); selectLesson(lesson:Lesson) { this.lessonEmitter.emit(lesson); } }
Now let's take a closer look at this component: it does not have the lessons service injected into it via its constructor. Instead, it receives the lessons in an input property via @Input.
This means that the component itself does not know where the lessons come from:
- the lessons might be a list of all lessons available
- or the lessons might be a list of all the lessons of a given course
- or even the lessons might be a page in any given list of a search
We could reuse this component in all of these scenarios, because the lessons list component does not know where the data comes from. The responsibility of the component is purely to present the data to the user and not to fetch it from a particular location.
Here is an example smart component that makes use of the presentation component above:
import { Component, OnInit } from '@angular/core'; import {LessonsService} from "../shared/model/lessons.service"; import {Lesson} from "../shared/model/lesson"; @Component({ selector: 'app-home', template: ` <h2>All Lessons</h2> <h4>Total Lessons: {{lessons?.length}}</h4> <div class="lessons-list-container v-h-center-block-parent"> <lessons-list [lessons]="lessons" (lesson)="selectLesson($event)"></lessons-list> </div> `, styleUrls: ['./home.component.css'] }) export class HomeComponent implements OnInit { lessons: Lesson[]; constructor(private lessonsService: LessonsService) { } ngOnInit() { // subscribe to the lessonsService to retrieve the lessons[] ... } selectLesson(lesson) { // do some action ... } }
9. Properly unsubscribe from observables
Observables need to be unsubscribed from when its containing component is destroyed, otherwise the subscription will hang around in the background causing all sorts of unexpected problems. There are a couple of ways to ensure that observables are unsubscribed from on component destruction:
9.1 Use of async pipe
Binding to observables in the template using the async pipe | async on the template side is generally preferred when possible. This will automatically unsubscribe from an observable when the component is destroyed. This is best used when you can subscribe to an observable directly without using pipe() and can use the data directly in the template without having to apply any logic in the component.
9.2 Using take(1)
take(1) is an operator that will automatically unsubscribe from an observable when one value is received.
data$.pipe(take(1)).subscribe(res=>console.log(res))
9.3 Using takeUntil
takeUntil is another operator that is to be used when you want to monitor the two observables and dispose of the subscription after the observables emit the value or get completed. This can be useful to unsubscribe from all observables when destroying a component using this pattern:
class myComponent { private _destroy$: Subject<void> = new Subject(); constructor( private _messageService: MessageService) {} ngOnInit() { this._messageService .pipe(takeUntil(this._destroy$)) .subscribe(...); } ngOnDestroy() { this._destroy$.next(); this._destroy$.complete(); } }
10. Avoid using functions in template rendering
Using functions in template rendering, such as in *ngIf, *ngFor, or in {{interpolation}}, with default change detection (as in, not onPush change detection) can result in horrible performance issues. This is because Angular cannot determine the result of a function without running the function every time; it has no guarantee that the result will be the same given the same set of input parameters. This doesn't sound too bad by itself, but if you have a component somewhere in the tree that has an on mouse move event for example, it will need to run that function every time the mouse is moved.
To solve this, you can switch out functions for pre-calculated property references or pure pipes. Having functions in the template for events (e.g. <button (click)="onClick()">) is still ok.
Bad:
<p>Welcome {{ fullName() }}!</p> <a href="files" *ngIf="hasAccessTo('files')">Files</a>
Good:
<p>Welcome {{ fullName }}!</p> <a href="files" *ngIf="'files' | hasAccessTo">Files</a>
This will also require changes in the component logic to pre-calculate the value of fullName, moving the original fullName() function call to wherever the underlying data for fullName() is changed and storing that value in fullName. It will also require moving hasAccessTo() to another file and defining it as a pipe has-access-to.pipe.ts, assuming the function can be written in a way that is pure, as in, it returns the same result with the same inputs every time.
11. Dealing with ExpressionChangedAfterItHasBeenChecked Error
One of the most common and confusing errors in Angular development is the ExpressionChangedAfterItHasBeenChecked error. The error arises when the binding expression changes after angular checked it during the change detection cycle. For more information as to why this happens and how to resolve it, check this Reference
12. RxJS and Reactive Programming
RxJS is a Reactive Programming library that is used extensively in Angular. It can be difficult to get used to thinking imperatively rather than declaratively, and the topic is rather extensive, but here are some of the basics and the most used concepts in Angular.
In the most basic of terms, there are streams of data called observables that can be subscribed to and also operators that can alter the form of the streams of data. RxJS provides multiple objects that can be exposed as an observable, the most basic of which is the Subject. Here's an example of what it looks like:
const subject = new Subject<number>(); const observable = subject.asObservable(); ngOnInit() { subject.next(5); observable.pipe( tap((value) => console.log('Initial Value: ', value)), map((value) => value * 2) ).subscribe((value) => { console.log('Post-Map Value: ', value); }); subject.next(1); subject.next(2); subject.next(3); }
Output:
Initial Value: 1 Post-Map Value: 2 Initial Value: 2 Post-Map Value: 4 Initial Value: 3 Post-Map Value: 6
What we've done is define a new Subject, exposed it as an observable (this is kind of pointless in this contrived example, usually the Subject would be a private member in a service and the observable would be the way it is exposed publicly. This is to prevent users of the service to providing values to the Subject directly using .next()), applied some operators to the observable using pipe(), subscribed to the stream and push some values to the Subject using .next().
The other most common subject is the BehaviorSubject which functions very similarly to the Subject, but has a starting value when you initialize it, and it will always push its current value immediately when subscribed to. Notice how in the above example, the subject.next(5) does not have its result printed to the console, because it is done before the subscription is made.
There are a lot of operators, all of which you can see here, but the most common ones are:
- tap - Transparently perform actions or side-effects, such as logging.
- filter - Emit values that pass the provided condition.
- take - Emit provided number of values before completing
- takeUntil - Emit values until provided observable emits.
- map - Apply projection with each value from source.
- switchMap - Map to observable, complete previous inner observable, emit values.
- combineLatest - When any observable emits a value, emit the last emitted value from each.
There are slight variations of map, like switchMap and mergeMap that function slightly differently and some are better for certain situations, as well as variations of combineLatest like withLatestFrom and forkJoin, so be sure to consult the reference to find which one fits your needs if you find yourself trying workarounds to fit the tool to your use case.
Sources:
- https://aglowiditsolutions.com/blog/angular-best-practices/
- https://www.tektutorialshub.com/angular/expressionchangedafterithasbeencheckederror-in-angular/
Join The Discussion