Guidelines for creating performant Angular applications and their efficient maintenance

Everything you need in one article!

Patrik Horvatić
19 min readJan 2, 2024

Thank you for showing interest in this article. My name is Patrik, and I am a mid-level Angular developer who works on and actively maintains medium and large-sized Angular projects. In the first part of the publication, we will discuss the efficient maintenance of big Angular projects. In the second part of the publication, we will focus on performance, providing data-backed evidence for why these approaches are the best. Let’s dive in!

Know your tools

I cannot stress enough how many developers neglect or choose not to explore or learn optimization tools. It is deliberately shooting yourself in the foot. If you want to create performant apps, you must actively profile their performance. Testing how it works is just not enough. You need numerical and statistical data to discover bottlenecks and issues.

The Angular team has provided an amazing tool called Angular DevTools. Angular DevTools is a browser extension that offers debugging and profiling capabilities for Angular applications. I encourage you to read about it in the documentation. Knowing and using DevTools is a must when creating and maintaining large Angular applications. Having numerous components, asynchronous tasks, and complex business logic will make your debugging and maintenance process much more challenging.

Typescript and JSDoc — get the best of both

TypeScript brings amazing features and enhances the development experience. In large Angular applications, you can expect a great number of types and interfaces originating from your code or external APIs. It is important to declare all types of data you will use in your application. Type and interface names must be meaningful and self-explanatory.

Code is often a long-term asset, and team members may change over time. Documentation ensures that knowledge about the codebase is not lost when developers leave or move to other projects. You can use JSDoc to describe the type, where it is used, why it exists, and its properties. That way, you do not need to explore its function and meaning from the code but from the description itself. For example:

/**
* Subscription type of the authenticated user.
* User can, but is not required to have a subscription.
*/
export type Subscriptions = 'basic' | 'advanced' | 'premium' | null

/**Basic data about the authenticated user. */
export type User = {
firstName: string;
lastName: string;
email: string;
phone: string | null;
avatar: string | null;
subscription: Subscriptions
};


/**
* Contains all the data received after successfull authentication
*/
export type UserAuthentication = {
/**JWT needed for accessing sensitive data*/
accessToken: string;

/**Basic data about the authenticated user */
userData: User;

/**
* Required for fetching fresh accessToken
* after its expiration
*/
refreshToken: string;
}

By adding JSDoc to the code, your IDE will have the description prepared. On mouse hover, you can read the prepared documentation, as seen in the image below:

Image 1: On mouse hover type documentation

One thing to keep in mind is that TypeScript does not ensure absolute type safety. Types assigned to a variable do not guarantee that the corresponding data type will be received from external APIs. You can think of TypeScript as a highly intelligent linter. It helps you catch instances where types don’t match, something is missing, or something is not done according to its configuration.

External data validation

It is mandatory to validate received data to ensure that your application does not break. One of the libraries for data type validation is Zod.js. Zod is a TypeScript-first schema declaration and validation library. I’m using the term “schema” to broadly refer to any data type, from a simple string to a complex nested object.

In my projects, I always make sure to thoroughly check and validate API responses. Unverified changes on the backend can seriously impact the app’s stability, so I take extra care in this area. The backend services come with API docs that lay out all the available endpoints and give clear instructions on how to use them. These docs also include response examples showing the data sent to the client.

I find these response examples super helpful for creating types and interfaces that match the transmitted data. This not only helps with consistency but also makes the app more robust. I’ve summarized the process in a flow diagram below:

API data fetch process

Directory structure and naming standards

Organization is a key factor in maintaining the app. If you struggle to find a component in your app, you should rethink your code structure. Directories, components, services, resolvers, classes, interfaces, functions, and variables have to be logically placed and meaningfully named. Their names have to be self-explanatory, indicating what they are, what they represent, and what their purpose is.

For example, consider a registration form with multiple input components that are used in more than four pages/components. How do we name them? Where do we declare them? How complex are they? What is their purpose?

<app-form-container>
<app-form-label value="Username" />
<app-form-text [(username)]="username" />

<app-form-label value="Email" />
<app-form-email [(email)]="email"/>

<app-form-label value="Birthday" />
<app-form-date [(date)]="birthday"/>

<app-form-label value="Password" />
<app-form-password [(password)]="password"/>

<app-form-label value="Confirm password" />
<app-form-password [(password)]="passwordConfirm"/>

<app-form-submit (submitClicked)="validateAndRegisterIfPossible()" />
</app-form-container>

Lets see one way of looking at this issue:

  • Those are components. They belong in “Components” directory.
  • They have “form” in their tags. They are all related to forms, so we will put them in “Form” directory that is inside “Components” directory
  • These components are all forms related so we can put them together in one module called “FormComponentsModule”. We can import that module where form components are needed.
  • Each component has its purpose. We have “app-form-container” which wraps other components and ensures responsivenes. “app-form-label” shows labels. “app-form-email” is designed to take email adress with proper format and keyboard configuration on mobile devices. “app-form-password” is designed to take in passwords with possibility of toggling visibility of the password and with proper format and keyboard configuration on mobile devices.

Another example: We have multiple pages where displaying data in graphs is required. There are multiple types of graphs. Next image displays good component naming and structure:

Image 2: Good component naming and organized structure

Method decorators

When working or building on big Angular projects, bugs are unavoidable. After each change, bug fix or update, your app must be tested. There is always the possibility that bugs or error can not be found or catched.

Method decorators come in very handy as you can be very creative with their functionality. One of the simplest decorators is logging the function execution.

export class ExampleComponent { 
@logFunctionExecution
protected wrappedFunction() {
// code
}
}

function logFunctionExecution(target: any, key: string, descriptor: PropertyDescriptor) {
console.log(`Executing ${key}()`);
}

Let’s create decorator function for error handling and logging. We need to be able to catch errors in method execution. Every time an error occoures, we must display it to the user interface and log it to the console.

export function logError(methodName: string, page: string) {
return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
try {
const result = originalMethod.apply(this, args);
return result;
} catch (error) {
try {
console.log(error)
displayError(error);
sendErrorToServer(serializeError(error, { maxDepth: 5, useToJSON: true }), methodName, page);
} catch (error) {
console.log(error);
}
}
};
return descriptor;
}
}

Now apply the decorator function to the class methods.

export class ExampleComponent {
@logFunctionExecution("wrappedFunctionOne", "ExampleComponent")
protected wrappedFunctionOne() {
// code
}
@logFunctionExecution("wrappedFunctionTwo", "ExampleComponent")
protected wrappedFunctionTwo() {
// code
}
@logFunctionExecution("wrappedFunctionThree", "ExampleComponent")
protected wrappedFunctionThree() {
// code
}
}

Now every time an error occoures you log it and display it to the UI.

NOTE: You may have noticed “sendErrorToServer” function. This is only neccessery when you are using for example, Ionic + Angular with Capacitor to wrap your web app for mobile devices. While testing, you do not have access to console log, so one of the solutions is to serialize your errors with serialize-error package and send them to the backend where you can keep track of them.

Method decorators are usefull, but don’t over use them. Having 8 decorators for your class methods is ugly and is not good practice. Here is great practical guide to Typescript decorators.

Angular app performance

Do you know how change detection works?

Change detection is the process through which Angular checks to see whether your application state has changed, and if any DOM needs to be updated. It plays a huge part when it comes to performance. At a high level, Angular walks your components from top to bottom, looking for changes. Angular runs its change detection mechanism periodically so that changes to the data model are reflected in an application’s view. Change detection can be triggered either manually or through an asynchronous event (for example, a user interaction or an XMLHttpRequest completion). Change detection is highly optimized and performant, but it can still cause slowdowns if the application runs it too frequently.

OnPush change detection strategy

The “OnPush” change detection strategy is a way to make components more efficient by reducing the frequency of change detection checks. When a component is configured with “OnPush,” Angular will only check for changes in the following scenarios:

  1. The component’s input properties have changed.
  2. The component explicitly requests a check via the ChangeDetectorRef service.
  3. An event originated from the component or one of its children is processed (e.g., a button click).

In other words, “OnPush” assumes that the component’s output depends only on its input and its own state. If these values remain unchanged, Angular skips the change detection for that component and its children, improving performance.

import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
selector: 'app-example',
templateUrl: 'example.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExampleComponent {

}

“trackBy” in for loops

“trackBy” function is used in conjuction with *ngFor structural directive and @for block. When using *ngFor structural directive, trackBy function is not mandatory, while in @for block is.

When rendering a list of items using for loop, Angular uses a default tracking mechanism based on object identity. However, in some cases, especially when dealing with dynamic lists or items that can be added, removed, or reordered, this default mechanism might not be sufficient for optimal performance. The trackBy function allows you to provide a custom way of identifying and tracking individual items in the list.

Let’s see this in an example

We will compare change detection speed in four cases.

  1. We will create a NewsService that prepares 10,000 items. We will have functions to return them asynchronously.
import { Injectable } from '@angular/core';
import { NewsMinimalData } from '../Types/news-types';

@Injectable({
providedIn: 'root'
})
export class NewsService {

private newsList!: Array<NewsMinimalData>;

constructor() { }

public returnAllNews(): Promise<Array<NewsMinimalData>> {
return new Promise((resolve) => {
this.createNewsList();
setTimeout(() => {
resolve(this.newsList);
}, 2000);
});
}

private createNewsList() {
this.newsList = [];
for (let i = 1; i < 10001; i++) {
this.newsList.push({
id: i,
title: "News title #" + i,
description: "Short description for News title #" + i,
});
}
}

}

2. Create simple NewsItem component

import { Component, Input } from '@angular/core';
import { NewsMinimalData } from '../../Types/news-types';

@Component({
selector: 'app-news-item',
template: `
<div>
{{news.id}}, {{news.title}}
</div>
<div>
{{news.description}}
</div>
`,
styles: `
:host {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 30px;
margin-bottom: 12px;
}
`,
standalone: true
})
export class NewsItemComponent {
@Input({ required: true }) news!: NewsMinimalData;
}

3. Import FormsModule and NewsItem in AppModule. If you are using it standalone, add these to the imports array.

4. Create basic template with simple functions.

<button (click)="addElement()">Add element</button>
<button (click)="removeElement()">Remove element</button>
<br>
<br>
<label>Data change</label>
<br>
<input placeholder="ID news to change" type="number" name="id" id="" [(ngModel)]="idToChange">
<br>
<input placeholder="New title of chosen news" type="text" name="text" id="" [(ngModel)]="newText">
<br>
<button (click)="changeData()">Change data</button>
<br>
<br>

<app-news-item *ngFor="let news of newsList" [news]="news" />

<!-- @for (news of newsList; track news.id) {
<app-news-item [news]="news" />
} -->

5. Add methods and properties in AppComponent

import { Component, OnInit } from '@angular/core';
import { NewsService } from './Services/news.service';
import { NewsMinimalData } from './Types/news-types';

@Component({
selector: 'app-root',
templateUrl: './app-component.html',
styles: []
})
export class AppComponent implements OnInit {

protected newsList!: Array<NewsMinimalData>;
protected idToChange!: number | null;
protected newText!: string | null;

constructor(private newsService: NewsService) {
}

ngOnInit() {
this.idToChange = null;
this.newText = null;
this.newsList = [];
this.prepareNews();
}

protected changeData() {
let index = this.newsList.findIndex(item => item.id === this.idToChange);
this.newsList[index] = { ...this.newsList[index], title: this.newText! };
}

protected addElement() {
this.newsList.unshift({
id: this.newsList.length + 1,
title: 'NEW ITEM',
description: 'Description for NEW ITEM'
})
}

protected trackNews(i: number, e: NewsMinimalData) {
return e.id;
}

protected removeElement() {
this.newsList.shift();
}

private async prepareNews() {
try {
this.newsList = await this.newsService.returnAllNews();
} catch (error) {
console.log(error);
}
}

}

Now we have everything prepared. We will extract data with Angular DevTools. Each case will have its video attached.

Device information:


.-----------.---------------------------------------------.
| OS | Ubuntu 23.10 64-bit, Linux 6.5.0-14-generic |
:-----------+---------------------------------------------:
| Processor | AMD Ryzen™ 7 5700U |
:-----------+---------------------------------------------:
| RAM | 16GB DDR4 SDRAM |
:-----------+---------------------------------------------:
| Graphics | NVIDIA GeForce GTX 1650 |
'-----------'---------------------------------------------'

Testing process:

  1. Prove there are 10000 elements
  2. Add element, get “Time spent” on change detection
  3. Remove element, get “Time spent” on change detection
  4. Change data of 5th element, get “Time spent” on change detection

CASE 1: ngFor without OnPush and without trackBy function

CASE 2: ngFor with OnPush and without trackBy function

CASE 3: ngFor with OnPush and with trackBy function

CASE 4: @for with OnPush and with trackBy function

Let’s extract the data. The GIF-s above are recorded worst case results, which are also in the table below:

Table: Performance of cerain cases

Conclusion from the data: Using @for with OnPush is the fastest way to detect changes.

NOTE: There is possibility that removing or updating the element from the list is slower when there is no trackBy function, which I found very interesting. After many tests, it turns out it was a very rare case. Removing or updating the element was little bit slower than the last case.

runOutsideAngular

The runOutsideAngular method is a way to run a function outside of the Angular zone. The Angular zone is a mechanism that tracks changes to the application state and triggers change detection when needed. By running a function outside the Angular zone, you can prevent change detection from being triggered for the operations performed within that function.

import { Component, NgZone } from '@angular/core';

@Component({
selector: 'app-example',
template: '<button (click)="runOutsideAngularExample()">Run Outside Angular</button>',
})
export class ExampleComponent {
constructor(private zone: NgZone) {}

runOutsideAngularExample() {
// Perform operations outside the Angular zone
this.zone.runOutsideAngular(() => {
// Operations here will not trigger Angular change detection
console.log('Running outside Angular zone');
});
}
}

In the example above, when the button is clicked, the runOutsideAngularExample method is called, and any operations inside the zone.runOutsideAngular function will not trigger Angular's change detection. This can be useful for tasks that don't need to update the Angular application state or UI, helping to improve performance in certain scenarios.

Lazy loading

Lazy loading in Angular is a technique that defers the loading of certain parts of an application until they are actually needed. Instead of loading the entire application when a user visits the site, the application loads only the essential components for the initial view. Additional modules or components are loaded on demand, typically triggered by the user’s actions, such as navigating to a specific route.

Lazy loading is commonly used with Angular’s router. You define routes for different parts of your application, and when a user navigates to a specific route, the associated module is loaded lazily. This is achieved by configuring the loadChildren property in the route definition.

const routes: Routes = [
{ path: 'lazy', loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule) },
// Other routes...
];

Lazy loading improves the initial loading time of an application, especially for large applications, by loading only the essential code needed for the current view. Subsequent parts of the application are loaded asynchronously as the user interacts with the app.

Avoid functions in template expressions

It is generally recommended to avoid using functions directly in template expressions. This is because functions in template expressions can have negative implications on the performance and maintainability of your application.

@Component({
selector: 'app-test',
template: `
{{function()}}
`,
})
class TestComponent {

protected function() {
//logic here...
}

}

Each time change detection occoures, function will be executed. This can easily backfire on your performance because too much CD and greater number of same components will slow down and make user interface laggy. If you must use functions in templates, use OnPush change detection strategy to somewhat optimize the performance.

Pipes

Pipes allow you to express data transformations in a declarative and readable manner directly within the template. This enhances the clarity of your template code and makes it more maintainable.

Pipes can be reused across different components and templates. Once you create a custom pipe, you can easily apply it wherever needed, promoting code reusability. They are very efficient with change detection and calculate values only when input values change.

Do not avoid RxJS

RxJS, or Reactive Extensions for JavaScript, is a library for reactive programming using Observables, which makes it easier to compose asynchronous or callback-based code. Reactive programming is an approach to programming that focuses on the propagation of changes and the declaration of data dependencies in a more declarative and less imperative way. RxJS provides a set of APIs for working with asynchronous data streams. The core concept is the Observable, which represents a stream of data or events that can be observed over time. Observables can be manipulated, combined, and transformed using a variety of operators provided by RxJS.

RxJS is commonly used with Angular. It helps manage and react to events, handle asynchronous operations, and simplify the flow of data in applications. By leveraging the principles of reactive programming, RxJS can lead to more concise, modular, and maintainable code for handling complex asynchronous scenarios.

Memory leak prevention

Memory leaks in Angular applications can occur when references to objects are not properly released, leading to an accumulation of unused objects in memory. Most common way of memory leaks are Observables. Unsubscribing from Observables is always a must!

@Component({...})
export class VideoUploadComponent implements OnInit {
protected videoSubject = new Subject();

ngOnInit() {
this.videoSubject.subscribe(videoData=> {
this.updateInterface(videoData);
});
}

uploadVideoFile(videoData: any) {
// Emit imageData to imageSubject
this.videoSubject.next(videoData);
}

updateInterface(videoData: any) {
// ...
}

ngOnDestroy() {
this.videoSubject.unsubscribe();
}
}

ngOnDestroy is here for a reason! Perfect time to clean up resources and deallocate some memory is right before components destruction. Memory leak presents a performance issue. As you use the app longer there is possibility that you will run out of memory, as it is not deallocated by the garbage collector.

Server-side rendering

Server-side rendering (SSR) in Angular refers to the process of rendering Angular applications on the server rather than in the browser. The traditional approach for Angular applications is client-side rendering (CSR), where the rendering and rendering logic are executed in the browser. With SSR, some or all of the rendering process is moved to the server before the content is sent to the client.

One of the main benefits of SSR is improved initial page load performance. When the server renders the initial HTML on the server side, the client receives a pre-rendered page, which can be faster for the user to view.

Production configuration

Before publishing or updating the app, make sure your angular.json configuration has production option for building.

Let’s see a good example of production configuration for “@angular-devkit/build-angular:application” builder:

{
"budgets": [{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"polyfills": ["src/polyfills.ts"],
"outputHashing": "media",
"optimization": true,
"extractLicenses": false,
"sourceMap": false,
"namedChunks": false,
"progress": true,
"statsJson": true,
"prerender": true,
"ssr": true,
"server": "src/main.server.ts",
"serviceWorker": "ngsw-config.json",
"aot": true
}

AOT (Ahead-of-Time) compilation in Angular is a build process that converts Angular application code, including templates and components, into efficient JavaScript code during the build phase. Advantages of AOT are faster startup, smaller bundle size and template error checking.By using AOT compilation, Angular applications can achieve better performance, faster loading times, and improved overall efficiency, making it a recommended practice for production deployments.

The “optimization” key in the “angular.json” file is a configuration option that controls whether the Angular CLI performs various optimizations during the build process. When set to “true”, the "optimization" key triggers various optimizations during the build process, such as minification of scripts and styles, tree-shaking, dead-code elimination, inlining of critical CSS and fonts inlining.

The “sourceMap” key should be set to false, as it is only neccessery for debuging and development only.

Configuration options can be found here.

Try to minimize external libraries and choose them wisely

Angular has wide range of component and function libraries/packages, but not all of them are performant! If you choose to use external libraries, make sure to do your research and choose the best that suits your needs. More external libraries increase size of your application, which is not desirable.

Utilize the power of Web Workers

Web Workers are a feature in web browsers that enable parallel execution of JavaScript code in the background, separate from the main browser thread. They allow developers to run time-consuming tasks, such as complex computations or data processing, without affecting the responsiveness of the user interface. Web Workers communicate with the main thread through a message-passing mechanism, promoting concurrency and improved performance in web applications.

The main thread in a web browser is responsible for handling various essential tasks related to the user interface and overall functionality of a web page. Its primary responsibilities include UI rendering, JS execution, event handling, layout and style calculations, network requests etc.

This sound like a lot of work for only just one thread! How about we move heavy computation to the Web Worker. This article explains in great detail benefits of Web Workers and how to use them. I will only provide you configuration steps for Angular apps:

  1. Create config file tsconfig.worker.json. It should be in the same directory as tsconfig.json. In my case I add all worker files in src/app/Workers directory.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/worker",
"lib": [
"es2022",
"webworker"
],
"types": []
},
"exclude": [],
"include": [
"src/app/Workers/*.worker.ts"
]
}

2. In angular.json file add created worker config file.

{
...
"projects": {
"app": {
...
"architect": {
...
"build": {
"options": {
...
"webWorkerTsConfig": "tsconfig.worker.json",
...
}
...
}
...
}
...
}
}
...
}

3. Initiate worker in app.component.ts file.


@Component({
...
})
export class AppComponent implements OnInit {
protected worker: Worker;

constructor() {}

ngOnInit() {
this.worker = new Worker(new URL(relative_path_to_worker_file, import.meta.url));

this.worker.onmessage = (data) => {
// do something with received data/result
}

this.worker.onerror = (err) => {
console.lor(err);
}

this.worker.postMessage(379560249782563);
}

}

Animating pages

Do you hate frame drops? Do you hate janky animations? Me too. Let’s see how to optimize your animations!

Browser painting, also known as rendering or reflow, is the process by which a web browser converts HTML, CSS, and other resources into a visual representation on the user’s screen. This process involves several steps, including layout, paint, and composite, and it is a crucial part of the browser rendering pipeline. Two key steps are layout and paint.

Layout step: The browser calculates the position and size of each element on the page based on the computed styles. This step is also known as reflow. The layout process determines how elements are arranged on the screen.

Painting step: Once the layout is determined, the browser goes through the painting phase. In this step, it creates the actual pixels that will be displayed on the screen. It involves filling in the pixels for each visible element, taking into account styles such as colors, backgrounds, borders, and images.

Composite step: The painted elements are composited together to create the final visual representation of the web page. This involves stacking the layers in the correct order and blending them to produce the desired output.

Inefficient use of styles, frequent layout changes, or large and complex web pages can lead to performance issues and slow rendering times. In the context of browser painting and performance optimization, some CSS styles are considered “compositor-only” or “cheap” because they don’t trigger layout or paint operations. These styles are generally applied by the browser’s compositor without causing a reflow or repaint, making them more efficient for animations and transitions.

  1. opacity
  2. transform
  3. filter
  4. backface-visibility
  5. will-change

The will-change property is a hint to the browser that a specific property is likely to be animated or changed in the future. This allows the browser to optimize its rendering pipeline. Overusing it can lead to negative performance impacts.

Let’s see the difference in using absolute positions vs transforming the page. I will skip the configuration part and will only add animation code. We will enable Paint flashing in Rendering options of Goolge Chrome browser. Less flashing means better performance.

Animation utilizing absolute positioning:

import {
trigger,
transition,
style,
query,
group,
animate,
AnimationQueryOptions,
} from '@angular/animations';


export const slideToRight = () => {
const optional: AnimationQueryOptions = { optional: true };
return [
style({ position: 'relative' }),
query(':enter, :leave', [
style({
position: 'absolute',
top: 0,
left: 0,
width: '100vw',
height: '100vh'
})
], optional),
query(':enter', [
style({
position: 'absolute',
top: 0,
left: '100%',
width: '100vw',
height: '100vh'
})
], optional),
group([
query(':leave', [
animate('500ms ease', style({ left: '-100%' }))
], optional),
query(':enter', [
animate('500ms ease', style({ left: '0%' }))
], optional)
]),
];
};
Image: Paint flashing using absolute positioning

Now let’s see animation utilizing transform CSS property:

import {
trigger,
transition,
style,
query,
group,
animate,
AnimationQueryOptions,
} from '@angular/animations';


export const slideToRight = () => {
const optional: AnimationQueryOptions = { optional: true };
return [
style({ position: 'relative' }),
query(':enter, :leave', [
style({
position: 'absolute',
top: 0,
left: 0,
width: '100vw',
height: '100vh'
})
], optional),
query(':enter', [
style({ transform: 'translateX(100vw)' })
], optional),
group([
query(':leave', [
animate('500ms ease', style({ transform: 'translateX(-100vw)', opacity: '0.75' }))
], optional),
query(':enter', [
animate('500ms ease', style({ transform: 'translateX(0)', opacity: '1' }))
], optional)
]),
];
};

Those are pretty simple animations and there is not much content on these pages. With much more content and growing animation complexity, painting process can harm your users experience. Smooth animations are important for UX, so try to implement your animations with “compositor-only” styles!

That’s all folks! I hope you learned something new from this article. Thank you for reading.

--

--