Writing clean, maintainable, and performant Angular applications requires following best practices that enhance readability, testability, and performance.
Here are 6 key principles every Angular developer should follow:
1. Avoiding logic in Angular templates
Avoiding logic in Angular templates helps maintain cleaner, more maintainable code. Here’s why and how you should do it:
Why Avoid Logic in Templates?
1. Performance Optimization — Function calls in templates are executed repeatedly during change detection, which can impact performance.
2. Better Testability — Moving logic to the component makes it easier to write unit tests.
3. Improved Maintainability — Keeping business logic separate from the template reduces the risk of breaking functionality when modifying the UI.
4. Enhanced Readability — Templates remain clean and focused on presentation rather than logic.
How to Extract Logic from Templates
Instead of doing this:
<div *ngIf="isUserLoggedIn()">{{ getUserName() }}</div>
Move the logic to the component:
export class ExampleComponent {
isLoggedIn = false;
userName = '';
constructor(private authService: AuthService) {
this.isLoggedIn = this.authService.isAuthenticated();
this.userName = this.authService.getUserName();
}
}
Then use it in the template:
<div *ngIf="isLoggedIn">{{ userName }}</div>
This ensures the function isn’t re-evaluated unnecessarily and improves performance.
2. Using Aliases for Cleaner Imports
In a deep folder structure, import paths can become long and messy.
Using aliases can help keep your import paths clean and manageable in such projects with deep folder structures.
Example of bad imports:
import { MyComponent } from '../../../components/MyComponent';
This import path is long, hard to understand, and can easily break when files are moved around.
Instead, aliases allow you to define a short path to access files and components, improving clarity and reducing the chances of errors.
In Angular projects, aliases are set up in the tsconfig.json file.
Here’s an example:
In the tsconfig.json file, we will use the paths property to define aliases.
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@app/*": ["src/app/*"],
"@services/*": ["src/app/services/*"],
"@components/*": ["src/app/components/*"]
}
}
Once you’ve set up your aliases, you can use them in your imports.
For example:
import { MyComponent } from '@components/MyComponent';
Instead of writing a long import path like:
import { MyComponent } from '../../../components/MyComponent';
How Aliases Improve Your Workflow:
Improved Readability: Aliases make it clear where your files are located. For instance, @components or @services give a clear context of what’s being imported, making your codebase easier to navigate.
Refactoring Made Easy: You can move files, folders, or entire sections of your application without worrying about breaking import paths throughout your project.
Consistency: With aliases, your import paths are consistent across the entire project, reducing the chance of errors.
3. Avoid Nested Subscriptions in RxJS
When handling multiple streams of data (like user input, API requests, or events), a common mistake is subscribing inside another subscription.
This leads to the following main problems:
- Multiple ongoing requests → If a new request starts before the previous one finishes, multiple requests run at the same time.
- Race conditions → The responses don’t always come back in order, so old data can overwrite new data.
- Memory leaks → Unnecessary active subscriptions can slow down the app and use extra memory.
Instead, we should use switchMap
switchMap makes sure that only the latest request is completed and all previous requests are canceled
How This Looks in Code (Nested Subscriptions)
pickupLocation$.subscribe(location => {
findDriver$(location).subscribe(driver => {
console.log(`Driver assigned: ${driver}`);
});
});
Problem:
If you update your location multiple times, multiple driver searches run at the same time.
The first request may return after the second, assigning the wrong driver.
Using switchMap (Cancels Old Requests)
pickupLocation$.pipe(
switchMap(location => findDriver$(location)) // Cancels old requests
).subscribe(driver => {
console.log(`Driver assigned: ${driver}`);
});
Fixed Problems:
Only one active search at a time (old requests are canceled).
Always get the correct driver for your latest request (no outdated responses).
Using switchMap ensures only the latest request matters, preventing outdated responses, unnecessary operations
4. Breaking large components into smaller, reusable component
As an Angular application grows, components tend to become large and difficult to maintain.
A single component that handles multiple responsibilities is harder to debug, test, and manage.
So, the idea is to split the large component into smaller ones that follows the Single Responsibility Principle (SRP), meaning each handle a specific task.
This way, you can test, debug, and reuse each smaller component more easily.
Example: Transforming a Large Component
Let’s say we have a large Angular component that:
Displays a list of items.
Filters items based on a search input.
Before Splitting: The Large Component
<!-- main.component.html -->
<div>
<input [(ngModel)]="searchTerm" placeholder="Search..." />
<div *ngFor="let item of filterItems(items, searchTerm)">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
</div>
</div>
// main.component.ts
export class MainComponent {
items = [
{ title: 'fist item', description: 'Description of Item 1' },
{ title: 'second item', description: 'Description of Item 2' },
{ title: 'third item', description: 'Description of Item 3' }
];
searchTerm = '';
filterItems(items: any[], term: string) {
return items.filter((item) => item.title.includes(term));
}
}
After Splitting: Smaller Components
To make the component more reusable, we break it down into:
1. Search Bar Component — Handles search input.
2. Item List Component — Displays a list of items.
3. Item Component — Displays a single item.
4. Search Bar Component (search-bar.component.ts)
<!-- search-bar.component.html -->
<input [(ngModel)]="searchTerm" (input)="search.emit(searchTerm)" placeholder="Search..." />
// search-bar.component.ts
import { Component, EventEmitter, Output } from '@angular/core';
@Component({
selector: 'app-search-bar',
templateUrl: './search-bar.component.html',
})
export class SearchBarComponent {
@Output() search = new EventEmitter<string>();
searchTerm: string = '';
}
2. Item Component (item.component.ts)
<!-- item.component.html -->
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
// item.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-item',
templateUrl: './item.component.html',
})
export class ItemComponent {
@Input() item: any;
}
3. Item List Component (item-list.component.ts)
<!-- item-list.component.html -->
<div *ngFor="let item of items">
<app-item [item]="item"></app-item>
</div>
// item-list.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-item-list',
templateUrl: './item-list.component.html',
})
export class ItemListComponent {
@Input() items: any[] = [];
}
4. Updated Main Component (main.component.ts)
<!-- main.component.html -->
<app-search-bar (search)="searchTerm = $event"></app-search-bar>
<app-item-list [items]="filterItems(items, searchTerm)"></app-item-list>
// main.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-main',
templateUrl: './main.component.html',
})
export class MainComponent {
items = [
{ title: 'Item 1', description: 'Description of Item 1' },
{ title: 'Item 2', description: 'Description of Item 2' },
{ title: 'Item 3', description: 'Description of Item 3' }
];
searchTerm = '';
filterItems(items: any[], term: string) {
return items.filter((item) => item.title.includes(term));
}
}
Why Break Large Components?
- Easier to Debug & Maintain — Smaller components mean fewer things can go wrong. If a bug appears, it’s easier to isolate and fix.
- Better Code Organization — Each component handles one task, making the project easier to navigate.
- Better Reusability — Extracting common UI elements prevents duplicate code.
- Improved Readability — Smaller files are easier to navigate.
- Faster Testing — Unit testing becomes much simpler because each component has a single purpose.
5. Documenting code
Documenting code with comments is an excellent practice, because it improves code maintainability, readability, and collaboration, making it easier for new developers (or even yourself in the future) to understand the logic.
Example 1: Documenting a Method
/**
* This method converts a given age into a string.
* It takes a number as input and returns the string version of it.
*
* @param age The age value to be converted.
* @returns A string representation of the age value.
*/
function getAge(age: number): string {
return age.toString();
}
Example 2: Documenting a Variable
For class variables, documenting them can also be helpful.
class UserProfile {
/**
* The name of the user.
* This is typically initialized when the user registers.
* @example "John Doe"
*/
userName: string;
/**
* The age of the user in years.
* This is an optional field and may not be available for every user.
* @example 28
*/
userAge?: number;
}
Angular Example: Documenting a Service Method
In Angular services, you can document methods as follows:
@Injectable({
providedIn: 'root'
})
export class UserService {
/**
* Fetches user data from the backend API.
* It sends a GET request to retrieve the user's profile information.
*
* @param userId The ID of the user whose data is to be fetched.
* @returns An Observable of the user's data.
*/
getUserData(userId: string): Observable<User> {
return this.http.get<User>(`/api/users/${userId}`);
}
}
Using clear, consistent commenting practices like this will make your code much more understandable.
6. Keep components lean by delegating complex logic to services.
In Angular, components and templates serve distinct roles in the app architecture.
The general rule is: components handle the logic, and templates handle the view. However, to avoid bloating your components with too much logic, it’s best to delegate business logic to services.
Example: Without a Service (Bad Practice)
Here, the component is doing too much.
import { Component } from '@angular/core';
@Component({
selector: 'app-products',
template: `<div *ngFor="let product of products">{{ product.name }}</div>`,
})
export class ProductsComponent {
products = [
{ id: 1, name: 'Jacket' },
{ id: 2, name: 'Sneakers' },
];
filterProducts(search: string) {
return this.products.filter(product => product.name.includes(search));
}
}
The filtering logic should be handled by a service instead.
Example: With a Service (Good Practice)
Move the logic to a service and inject it into the component.
Step 1: Create the Service
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class ProductService {
private products = [
{ id: 1, name: 'Jacket' },
{ id: 2, name: 'Sneakers' },
];
getProducts() {
return this.products;
}
filterProducts(search: string) {
return this.products.filter(product => product.name.includes(search));
}
}
Step 2: Use the Service in the Component
import { Component } from '@angular/core';
import { ProductService } from '../services/product.service';
@Component({
selector: 'app-products',
template: `<div *ngFor="let product of filteredProducts">{{ product.name }}</div>`,
})
export class ProductsComponent {
filteredProducts = [];
constructor(private productService: ProductService) {
this.filteredProducts = this.productService.getProducts();
}
searchProducts(search: string) {
this.filteredProducts = this.productService.filterProducts(search);
}
}
Why Delegate Logic to Services?
- Separation of Concerns — The component handles UI, and the service handles logic.
- Reusability — ProductService can be used in multiple components.
- Testability — We can test the service separately.
- Maintainability — Easier to modify or expand.
Conclusion
By applying these best practices, your Angular applications will be more efficient, maintainable, and scalable.
Check out my deep dives on → Gumroad
Top comments (0)