Angular is a robust, enterprise-grade framework, but with great power comes great responsibility. Because Angular gives us so much freedom, it is incredibly easy to shoot ourselves in the foot, especially as the application starts to scale.
If your app feels sluggish, or if your components are becoming an unmaintainable mess, chances are you are falling victim to a few common bad habits.
Let's look at 5 of the most frequent Angular anti-patterns and how to refactor them using modern, clean code practices tailored for Angular 21.
1. Function Calls in Templates (The Performance Killer)
The Anti-pattern: Calling a component method directly in your HTML template for data binding.
<!-- [BAD]: This will execute on every single Change Detection cycle -->
<p>Total: {{ calculateTotal(cartItems) }}</p>
<div [class.active]="checkIfUserIsActive(user)"></div>
Why it is bad: Angular's Change Detection (CD) is aggressive by default. Every time anything happens (a click, a timer, an HTTP response), Angular checks the template for changes. If you bind a function in the template, Angular has to re-execute that function every single CD cycle to see if the return value changed. If calculateTotal involves heavy array manipulation, your app will freeze.
The Modern Solution:
Use Pure Pipes for transformations, or better yet, use Angular 21 Signals.
// [GOOD]: Using Angular 21 Signals
cartItems = signal<CartItem[]>([]);
// computed() caches the value and ONLY recalculates if cartItems changes!
total = computed(() => this.cartItems().reduce((acc, item) => acc + item.price, 0));
<!-- [GOOD]: Just binding to the computed signal -->
<p>Total: {{ total() }}</p>
2. Abandoned Subscriptions (The Memory Leak)
The Anti-pattern: Subscribing to an Observable but forgetting to unsubscribe when the component is destroyed.
// [BAD]: This subscription lives forever, even if the user navigates away.
ngOnInit() {
this.userService.getUserData().subscribe(data => {
this.userData = data;
});
}
Why it is bad: When the Angular Router destroys this component, the subscription remains active in memory. This leads to memory leaks and ghost code executions in the background, causing weird UI bugs and crashing browsers.
The Modern Solution:
If you must use RxJS instead of Signals for a specific data stream, use the takeUntilDestroyed() operator. In modern Angular, you do not even need to implement ngOnDestroy for this.
// [GOOD]: Modern approach
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class UserComponent {
constructor(private userService: UserService) {
this.userService.getUserData()
.pipe(takeUntilDestroyed()) // Automatically unsubscribes on destroy!
.subscribe(data => this.userData = data);
}
}
3. Nested Subscriptions (The Reactive "Callback Hell")
The Anti-pattern: Subscribing to an Observable inside another Observable's subscribe block.
// [BAD]: Hard to read, impossible to test, and prone to race conditions.
this.route.params.subscribe(params => {
this.userService.getUser(params['id']).subscribe(user => {
this.projectService.getProjects(user.organizationId).subscribe(projects => {
this.projects = projects;
});
});
});
Why it is bad: It breaks the declarative nature of RxJS. More importantly, it creates race conditions. If the user clicks fast and triggers multiple ID changes, the HTTP responses might arrive out of order, rendering the wrong data on the screen.
The Modern Solution:
Leverage Higher-Order Mapping operators like switchMap, concatMap, or mergeMap.
// [GOOD]: Clean, linear, and handles race conditions safely.
this.projects$ = this.route.params.pipe(
switchMap(params => this.userService.getUser(params['id'])),
switchMap(user => this.projectService.getProjects(user.organizationId))
);
(Note: switchMap is perfect here because it cancels previous pending requests if a new ID is emitted).
4. "Fat" Components (The God Object)
The Anti-pattern: Stuffing business logic, direct HTTP calls, and complex state manipulation directly into the .ts file of your component.
Why it is bad: Your component becomes a monolith. It becomes untestable (because you have to mock 15 different dependencies) and impossible to reuse. UI components should only care about presenting data and catching user events.
The Modern Solution:
Adopt the Smart / Dumb (Presentational) Component Architecture using Standalone Components, and use a Facade or Service for logic.
Move your HTTP logic and state mutations into a dedicated Service, keeping your component clean:
// [GOOD]: The component just binds to the facade service.
@Component({
standalone: true,
// ...
})
export class CartComponent {
// Injecting the service using the modern inject() function
private cartFacade = inject(CartFacadeService);
cartItems = this.cartFacade.items; // This is a Signal exposed by the service
onRemoveItem(id: string) {
this.cartFacade.removeItem(id); // The service handles the actual logic and API call
}
}
5. The Desperate ChangeDetectorRef Hack
The Anti-pattern: Injecting ChangeDetectorRef and throwing cdr.detectChanges() or wrapping code in setTimeout(() => {}, 0) just to force the UI to update.
// [BAD]: Forcing Angular to update because the data flow is broken.
updateUser() {
this.user.name = 'John';
this.cdr.detectChanges(); // Please update the UI, I am begging you!
}
Why it is bad: If you find yourself using this, it means you have lost control of your state management or you are mutating data directly instead of creating new references. It is treating the symptom instead of curing the disease.
The Modern Solution:
Embrace Zoneless Angular and Immutability.
Angular 21 fully supports going Zoneless (provideZonelessChangeDetection()). In a Zoneless environment, the framework relies entirely on Signals and built-in reactive primitives to know when to update the DOM, eliminating the need for Zone.js overhead or manual cdr.detectChanges() hacks.
// [GOOD]: Treat data as immutable and use Signals for automatic UI updates
@Component({
standalone: true,
// ChangeDetectionStrategy.OnPush is also recommended if you are not fully zoneless yet
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserComponent {
user = signal({ name: 'Jane', age: 30 });
updateUser() {
// Updating the signal automatically schedules a highly optimized UI update
this.user.update(u => ({ ...u, name: 'John' }));
}
}
Wrapping Up
Angular has heavily shifted towards a reactive, Signal-driven, and Zoneless future. By dropping these 5 anti-patterns and embracing the paradigms of Angular 21, you will immediately notice improvements in your app's Lighthouse scores, memory footprint, and overall developer experience.
Over to you: Which of these anti-patterns do you see most often in legacy codebases? Are there any others you would add to this list? Let me know in the comments!
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more