Angular Lessons Learned the Hard Way
Angular has changed dramatically since version 2, and its size can make it difficult to grasp initially. Based on years of working with the framework, here are the most important lessons worth learning early, along with some best practices that will save you time and frustration.
Structure Applications With Modules and Responsibilities in Mind
Angular's official style guide and CLI scaffolding set you up with a sensible structure, but breaking out of tutorial territory often leads to files scattered across bloated modules. This typically causes scalability issues down the line. Three specific mistakes tend to come back to haunt developers:
Keep components organized by modules. Even with Standalone Components in Angular 14 removing the NgModules requirement, a modular folder structure remains beneficial. Instead of dumping everything into the default module, break things into:
- Core module — singleton services plus root-level components like navigation and footers.
- Feature modules — functionality-specific code. An e-commerce app might have a product module, a cart module, and an orders module.
- Shared module — components, directives, and pipes used across the app.
This makes boundaries clear enough that teams can work on separate features without increasing the risk of breaking other parts of the application.
Lazy load routes. When every component lives in a single root-level module, lazy loading isn't possible. Initial load time suffers as a result. Once components are split into separate modules, apply lazy loading so modules are only fetched when users navigate to routes that need them.
Apply the single responsibility principle. Components, services, pipes, and directives should perform a focused set of tasks. If a component is doing more than its original purpose, refactor and split it into smaller pieces to simplify testing and maintenance.
Leverage the Angular CLI's Full Capabilities
Most developers know ng serve, but the CLI offers far more, particularly for scaffolding. Creating component boilerplate manually works for small projects but becomes tedious as the project grows. Instead of writing out a card component's files and wiring it into the right module yourself, the CLI's generate command handles all of it for you.
ng g c card
Install the CLI globally with npm:
npm install -g @angular/cli
To see all the available commands, run:
ng help
Projects often need custom configuration on top of what the CLI generates. Angular's schematics address this — they are template-based code generators that can be packaged and installed via npm into whichever project needs them.
Simplify Imports With Path Aliases and Barrel Exports
Organizing files into folders like services and models eventually generates long, ugly imports:
import { UserService } from '../../services/user.service';
import { RolesService } from '../../services/roles.service';
TypeScript path aliases simplify these statements. Configure them in tsconfig.json by mapping a desired path name to the actual path:
{
"compilerOptions": {
"paths": {
"@services/*": ["src/app/services/*"],
}
}
}
The previously unwieldy imports become:
import { UserService } from '@services/user.service';
import { RolesService } from '@services/roles.service';
Beyond readability, path aliases mean you can move files around without updating every relative import in the project.
Barrel exports take this a step further. Add an index.ts file inside the services folder that re-exports every file in it:
export * from './user.service';
export * from './roles.service';
Update tsconfig.json to point to that index.ts file instead of the asterisk:
{
"compilerOptions": {
"paths": {
"@services": ["src/app/services/index.ts"],
}
}
}
The import statements become even simpler:
import { UserService, RolesService } from '@services';
Lean Into TypeScript Instead of Avoiding It
For developers coming from JavaScript, TypeScript's type system feels like extra baggage, and falling back on any everywhere is a footgun. The language offers real benefits once it's given a fair chance, including IntelliSense and compile-time static type checking that catches whole classes of bugs early. Its configuration in tsconfig.json is also flexible — you can set rules as loose or as strict as your project needs.
Use trackBy in Loops for Better Performance
Performance problems surfaced in real-world Angular apps tend to be UI-related, especially with loops that re-render frequently. Without trackBy, Angular removes all the DOM elements associated with updated items and re-creates them. That's expensive DOM work that causes flicker.
The ngFor directive needs to uniquely identify items in the iterable to update only what changed. Here's what a normal ngFor over a user array looks like:
@Component({
selector: 'my-app',
template: `
<div *ngFor="let user of users">
{{ user.name }}
</div>
`,
})
export class App {
users = [
{id: 1, name: 'Will'},
{id: 2, name: 'Mike'},
{id: 3, name: 'John'},
]
}
Adding the trackBy function gives Angular that unique identifier so it updates only the affected elements:
@Component({
selector: 'my-app',
template: `
<div *ngFor="let user of users; trackBy: trackByFn">
{{ user.name }}
</div>
`,
})
export class App {
users = [
{id: 1, name: 'Will'},
{id: 2, name: 'Mike'},
{id: 3, name: 'John'},
]
trackByFn(index, item) {
return item.id;
}
}
Any loop that's rendered or updated frequently — whether data is regularly added, removed, reordered, or refreshed — should use trackBy tied to a property like the item's id.
Transform Data in Templates With Pipes
Two common approaches to data formatting in templates are both problematic: binding directly to a function that transforms each render, or creating and binding an intermediate formatted variable. Neither is clean or performant.
interface User {
firstName: string,
middleName: string,
lastName: string
}
@Component({
selector: 'my-app',
template: `
<h1>{{ formatDisplayName(user) }}</h1>
`,
})
export class App {
user: User = {
firstName: 'Nick',
middleName: 'Piberius',
lastName: 'Wilde'
}
formatDisplayName(user: User): string {
return `${user.firstName} ${user.middleName.substring(0,1)}. ${user.lastName}`;
}
}
interface User {
firstName: string,
middleName: string,
lastName: string
}
@Component({
selector: 'my-app',
template: `
<h1>{{ displayName }}</h1>
`,
})
export class App {
user: User = {
firstName: 'Nick',
middleName: 'Piberius',
lastName: 'Wilde'
}
displayName = `${this.user.firstName} ${this.user.middleName.substring(0,1)}. ${this.user.lastName}`;
}
The right approach is pipes. Angular ships with built-in pipes for uppercase and lowercase strings, dates, currency, decimals, percentages, and i18n. Custom pipes are easy to write for app-specific transformations:
@Pipe({name: 'displayName'})
export class DisplayNamePipe implements PipeTransform {
transform(user: User): string {
return `${user.firstName} ${user.middleName.substring(0,1)}. ${user.lastName}`;
}
}
Use it in the template with the pipe operator:
@Component({
selector: 'my-app',
template: `
<h1>{{ user | displayName }}</h1>
`,
})
export class App {
user: User = {
firstName: 'Nick',
middleName: 'Piberius',
lastName: 'Wilde'
}
}
Opt Into OnPush Change Detection Where It Makes Sense
Angular components form a tree; change detection walks that tree every time an event occurs, checking each component's change detector to see if a re-render is necessary. The default strategy runs the detection cycle on every event inside the component, regardless of whether it could possibly affect that component.
The OnPush strategy restricts when detection runs to:
- Event handlers inside the component
- Async pipes emitting new values
- Input reference changes
Adopting OnPush reduces change detection cycles and forces better architecture — each component must be more modular and rely on async pipes, immutable inputs, or explicit event boundaries to stay in sync. That discipline pays off in both performance and maintainability.
Working With RxJS, Not Around It
RxJS is a cornerstone of Angular, even if you don't actively reach for it. The framework's core pieces — Routing, HttpClient, and FormControl — are built on observables. Many developers initially try to sidestep RxJS because it introduces a way of thinking that differs from JavaScript's familiar Promise model. Streams and observables are a paradigm shift, and it's common to avoid them until a problem forces the issue.
Once you invest time in understanding RxJS, its value becomes clear. The library provides a large collection of chainable operators that handle async logic elegantly. In practice, there's often an operator — or a combination — for nearly every use case you'll encounter. A few that come up frequently include:
map: applies a transformation function to each emitted value from the source.tap: run side effects (for example, updating outside state) when a value emits, without changing the stream itself.switchMap: maps each value to an inner observable and flattens the result, canceling the previous inner observable on new emissions.filter: only emits values that satisfy a given predicate.combineLatestWith: combines the latest values from the source and multiple observables into a single array emission.
Memory Leaks Are a Real, Avoidable Problem
Memory leaks rank among the most frustrating issues to debug in an Angular app. They often surface subtly — performance degrades the longer the application runs, or event handlers fire more than once. Two patterns account for most leaks I've hit.
Unmanaged Subscriptions
The async pipe handles cleanup for you, but manually calling subscribe does not. Every subscription you create lives as long as the observable does, unless you explicitly tear it down. Forget this, and each new component instance leaves behind a subscription that outlives it.
export class MyComponent {
constructor(private route: ActivatedRoute){
this.route.params.subscribe((params) => {
// Do something
});
}
}
You have two straightforward fixes. The first is to hold the subscription and call unsubscribe:
export class MyComponent {
private routeSubscription;
constructor(private route: ActivatedRoute){
this.routeSubscription = this.route.params.subscribe((params) => {
// Do something
});
}
ngOnDestroy() {
this.routeSubscription.unsubcribe();
}
}
The second is to use the takeUntil operator, which completes the observable when a designated notifier emits:
export class MyComponent {
private componentDestroyed$ = new Subject<boolean>();
constructor(private route: ActivatedRoute){
this.route.params.pipe(
takeUntil(this.componentDestroyed$)
).subscribe((params) => {
// Do something
});
}
ngOnDestroy() {
this.componentDestroyed$.next(true);
this.componentDestroyed$.complete();
}
}
Unregistered Event Listeners
The same problem occurs with native event listeners. A scroll listener, for instance, gets re-registered on every component instance if it's added directly in the component body. When the component is destroyed, those listeners remain attached and keep executing idle code.
export class MyComponent {
constructor(private renderer: Renderer2) {}
ngOnInit() {
this.renderer.listen(document.body, 'scroll', () => {
// Do something
});
}
}
To stop that, keep a reference to the listener and remove it inside ngOnDestroy:
export class MyComponent {
private listener;
constructor(private renderer: Renderer2) {}
ngOnInit() {
this.listener = this.renderer.listen(
document.body,
‘scroll’,
() => {
// Do something
});
}
ngOnDestroy() {
this.listener();
}
}
When to Bring in a State Management Library
Client-side state management isn't something you need on day one. Small, simple applications can get by with component services and Angular's built-in mechanisms. The need appears when the app grows and sharing state across views becomes convoluted. At that point, a dedicated library can add clarity, though there is no universal solution — each project's constraints differ.
If you choose to adopt one, the Angular ecosystem has several mature options that take different approaches:
- NgRx
- NGXS
- Akita
The right pick depends on your architecture, team familiarity, and how strictly you want to enforce unidirectional data flow.
The Learning Curve Is Worth It
Angular has a notorious ramp-up, and it's normal for the framework not to click immediately. The pieces — modules, DI, RxJS, change detection — are interconnected, and their purpose becomes clearer with hands-on use. Expect a period of friction, especially if you came from a lighter-weight stack. The reward for pushing through is a structured framework with strong defaults for large-scale applications. Patience here pays off, as does deliberately exploring areas like RxJS rather than avoiding them. The mistakes you make early on are the best guide for what to study next.



