Common Angular Pitfalls
Angular's power comes with sharp edges that trip up developers at every experience level. Most production bugs in Angular apps trace back to a handful of recurring mistakes: unmanaged Observable subscriptions, mutating signal or object state in ways that bypass change detection, misunderstanding how OnPush interacts with mutation, and misconfigured dependency injection scopes. Recognizing these patterns—and knowing the idiomatic fix—separates code that merely works from code that holds up under real-world load and refactoring.
Cricket analogy: Just as most run-outs trace back to a handful of recurring mistakes like poor calling or ball-watching rather than exotic errors, most Angular production bugs trace back to a handful of recurring patterns like leaked subscriptions and mutation-bypassed change detection.
Forgetting to Unsubscribe from Observables
Subscribing to an Observable inside a component without ever unsubscribing keeps that subscription alive even after the component is destroyed, silently leaking memory and, worse, continuing to run callback logic (like navigation or state updates) against a component that no longer exists. This is especially dangerous with long-lived Observables like route params, WebSocket streams, or interval timers.
Cricket analogy: An unsubscribed Observable is like a substitute fielder who stays on the ground running plays long after the umpires have called the match over, still reacting to balls nobody bowled, especially dangerous with a long-running spell like a rain-delay commentary feed.
// PITFALL: subscription never cleaned up
@Component({ selector: 'app-leaky', standalone: true, template: `` })
export class LeakyComponent implements OnInit {
constructor(private route: ActivatedRoute) {}
ngOnInit() {
this.route.params.subscribe((params) => {
console.log(params); // keeps firing even after component is destroyed
});
}
}
// FIX 1: async pipe (auto-unsubscribes) — prefer this when possible
// template: <div>{{ params$ | async | json }}</div>
// params$ = this.route.params;
// FIX 2: takeUntilDestroyed with DestroyRef
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ selector: 'app-fixed', standalone: true, template: `` })
export class FixedComponent implements OnInit {
private route = inject(ActivatedRoute);
private destroyRef = inject(DestroyRef);
ngOnInit() {
this.route.params
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => console.log(params));
}
}Mutating Signals and Objects Directly
Calling .update() or .set() is required to notify Angular's reactive graph of a signal change; directly mutating an object or array held inside a signal (e.g., mySignal().push(item)) changes the underlying data but does not create a new reference, so dependent computed()/effect() callbacks and OnPush components may not re-run, since Angular didn't detect a 'new' value. The fix is to always produce a new object or array: mySignal.update(arr => [...arr, item]).
Cricket analogy: Mutating an array inside a signal is like a scorer scribbling a correction directly onto the old scoresheet instead of issuing a fresh official one — the umpires' review system, watching for a brand-new sheet, never notices the change and the scoreboard stays stale, which is why you must call mySignal.update(arr => [...arr, item]) to hand over a genuinely new sheet.
Misunderstanding OnPush with Mutated Inputs
A component using ChangeDetectionStrategy.OnPush only re-checks when an @Input() *reference* changes. If a parent mutates an object or array in place and passes the same reference down, the OnPush child never re-renders, because Angular's reference comparison (===) sees no difference—even though the data changed. This surprises developers coming from patterns where deep mutation was previously 'fine' under default change detection (which dirty-checks values less strictly, though it too benefits from immutability).
Cricket analogy: An OnPush component only re-checks on a new @Input() reference, like a third umpire who only reviews a decision when handed a fresh camera angle — if the broadcaster reuses the same old footage after a replay edit, the umpire's ruling never changes even though the play actually looked different.
Immutability isn't just a stylistic preference in Angular—it's the mechanism that makes OnPush and signal-based reactivity actually work correctly. Treating state as immutable (always replacing rather than mutating) is the single habit that prevents the largest category of 'my component didn't update' bugs.
Providing a Service at the Wrong Injector Level
Registering a service in providedIn: 'root' makes it a true application-wide singleton, but re-adding that same service to a component's own providers array creates a *separate* instance scoped to that component subtree—developers are sometimes surprised when two sibling components each get their own isolated copy of what they assumed was shared global state. Conversely, forgetting to scope a service that's meant to be feature-local (e.g., wizard step state) at the root level instead can cause state to leak across unrelated parts of the app that shouldn't share it.
Cricket analogy: A providedIn: 'root' service is like a single national team physio shared by every franchise, but re-registering that same service in one team's own providers gives that franchise its own private physio — sibling teams assuming they shared the same treatment records are surprised to find separate ones.
Circular subscriptions are another classic trap: subscribing to an Observable inside another subscription's callback (nested subscribes) instead of using switchMap/mergeMap/concatMap creates hard-to-debug cascades and often duplicate or out-of-order side effects. If you find yourself writing .subscribe(() => { otherObservable$.subscribe(...) }), it's almost always better expressed as a flattening operator in a single pipe().
- Unmanaged Observable subscriptions leak memory and can run logic against destroyed components — prefer the async pipe or takeUntilDestroyed().
- Mutating a signal's underlying object/array directly doesn't notify Angular's reactive graph; always .update() with a new reference.
- OnPush components only re-check on @Input() reference changes, so in-place mutation of objects passed as inputs silently breaks re-rendering.
- Re-registering a root-provided service in a component's providers array creates a separate scoped instance, not a shared singleton.
- Nested/manual subscriptions inside other subscriptions should almost always be replaced with a flattening RxJS operator like switchMap.
- Immutable state updates are the underlying discipline that makes both OnPush and signal reactivity behave predictably.
Practice what you learned
1. Why is manually calling .subscribe() without cleanup risky in a component?
2. Why does calling `mySignal().push(item)` fail to trigger dependent computed()/effect() updates?
3. An OnPush child component receives an array as an @Input(). The parent mutates that array in place and passes it down again. What happens?
4. What happens if a service is registered with providedIn: 'root' AND also listed in a component's own `providers` array?
5. What is the recommended fix for nested subscribe() calls (subscribing inside another subscription's callback)?
Was this page helpful?
You May Also Like
Introduction to Signals
Understand Angular Signals — the reactive primitive that wraps a value and notifies dependents on change, forming the foundation of Angular's modern change-detection model.
Change Detection and Zone.js
Understand how Angular detects and applies UI updates via Zone.js, how the change detection tree works, and how zoneless change detection with signals changes the model.
Hierarchical Dependency Injection
Understand how Angular's injectors form a tree mirroring the component hierarchy, how token resolution walks up that tree, and how to scope service instances deliberately.
RxJS Operators for HTTP (map, switchMap, catchError)
Master the core RxJS operators — map, switchMap, catchError, and their flattening cousins — used to transform, chain, and recover from HTTP requests.
Related Reading
Related Study Notes in Web Development
Browse all study notesWebSockets Study Notes
Web Development · 30 topics
Web DevelopmentWebAssembly Study Notes
WebAssembly · 30 topics
Web DevelopmentgRPC Study Notes
Protocol Buffers · 30 topics
Web DevelopmentSpring Boot Study Notes
Java · 30 topics
Web DevelopmentFlask Study Notes
Python · 30 topics
Web DevelopmentDjango Study Notes
Python · 30 topics