DEV Community

Cover image for Angular Best Practices Every Developer Needs to Know
bytebantz
bytebantz

Posted on

Angular Best Practices Every Developer Needs to Know

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>
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

Then use it in the template:

<div *ngIf="isLoggedIn">{{ userName }}</div>
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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/*"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Once you’ve set up your aliases, you can use them in your imports.

For example:

import { MyComponent } from '@components/MyComponent';
Enter fullscreen mode Exit fullscreen mode

Instead of writing a long import path like:

import { MyComponent } from '../../../components/MyComponent';
Enter fullscreen mode Exit fullscreen mode

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:

  1. Multiple ongoing requests → If a new request starts before the previous one finishes, multiple requests run at the same time.
  2. Race conditions → The responses don’t always come back in order, so old data can overwrite new data.
  3. 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}`);
  });
});
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
// 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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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..." />
Enter fullscreen mode Exit fullscreen mode
// 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 = '';
}
Enter fullscreen mode Exit fullscreen mode

2. Item Component (item.component.ts)

<!-- item.component.html -->
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
Enter fullscreen mode Exit fullscreen mode
// item.component.ts
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-item',
  templateUrl: './item.component.html',
})
export class ItemComponent {
  @Input() item: any;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
// 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[] = [];
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
// 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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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)