The Angular Knowledge

Introduction

I’ve been writing Angular professionally for more than a decade. This article is the collection of things I wish someone had explained to me early, written in the order I think they’ll be most useful to you. Some of this covers concepts that have existed since the beginning. Some covers the modern Angular that looks meaningfully different from what most tutorials show.

All of it is aimed at making you a better Angular developer in practice, not just in interviews.

Change Detection: What’s Actually Happening

If you understand one thing deeply about Angular beyond components and services, make it change detection. Almost every Angular performance problem I’ve debugged traces back to it.

The Default strategy and why it’s expensive

By default, Angular uses ChangeDetectionStrategy.Default. Every time anything happens — a user event, a timer, an HTTP response, a resolved Promise — Angular runs change detection on every component in the entire component tree, from root to leaf. Not just the component that changed. All of them.

This is zone.js at work. Zone.js patches every async operation in the browser — setTimeout, setInterval, XHR, Promises, DOM events — and notifies Angular when any of them complete. Angular’s response is to check everything.

On a small component tree, this is invisible. On a component tree with hundreds of components, it becomes a real performance problem.

OnPush: what it actually does

ChangeDetectionStrategy.OnPush changes when Angular checks a component. Instead of checking on every cycle, Angular only checks a component when:

  1. One of its @Input() references changes (reference change, not deep equality)
  2. An event originates from the component or one of its children
  3. An async pipe in the template resolves
  4. You manually trigger it via ChangeDetectorRef
@Component({
  selector: 'app-ticket-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: ` <app-ticket *ngFor="let ticket of tickets" [ticket]="ticket" /> `,
})
export class TicketListComponent {
  @Input() tickets: Ticket[] = [];
}

The critical implication: if you mutate an object and pass the same reference, OnPush won’t detect the change. This is the most common OnPush bug I see.

// This will NOT trigger change detection in an OnPush component
this.ticket.status = 'resolved'; // Same reference, mutated

// This WILL trigger it — new reference
this.ticket = { ...this.ticket, status: 'resolved' };

Use OnPush everywhere you can. On a migration project I’ve worked on, switching components to OnPush as we migrated each module was one of the largest contributors to the performance improvements we saw. The change detection cycles dropped dramatically.

Detaching and reattaching manually

Sometimes you need even more control. ChangeDetectorRef gives you it:

@Component({
  selector: 'app-realtime-chart',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<canvas #chart></canvas>`,
})
export class RealtimeChartComponent implements OnInit, OnDestroy {
  private cdRef = inject(ChangeDetectorRef);
  private subscription?: Subscription;

  ngOnInit() {
    // Detach this component entirely from the change detection tree
    this.cdRef.detach();

    // Manually trigger a check only when data arrives
    this.subscription = this.dataStream$
      .pipe(
        throttleTime(16) // ~60fps max
      )
      .subscribe((data) => {
        this.updateChart(data);
        this.cdRef.detectChanges(); // Only check THIS component
      });
  }

  ngOnDestroy() {
    this.subscription?.unsubscribe();
  }
}

detectChanges() runs change detection from the current component downward. markForCheck() marks the component and its ancestors as dirty, so they’ll be checked on the next cycle. They serve different purposes — detectChanges() is synchronous and immediate, markForCheck() is scheduled.

Dependency Injection: The Parts Tutorials Skip

Angular’s DI system is significantly more powerful than @Injectable({ providedIn: 'root' }). Understanding the full picture changes how you architect services.

The injection hierarchy

Angular has a hierarchy of injectors: the platform injector, the root injector, module injectors, component injectors, and directive injectors. When you inject a service, Angular walks up this hierarchy until it finds a provider.

// Provided at the component level — each instance of this component
// gets its own instance of the service
@Component({
  selector: 'app-wizard',
  providers: [WizardStateService],
})
export class WizardComponent {}

This is how you scope a service to a component subtree. The wizard’s child components inject WizardStateService and they all get the same instance — scoped to that wizard. The next instance of WizardComponent gets a fresh WizardStateService. When the wizard is destroyed, so is its service instance.

I used this pattern for multi-step form flows. Each flow instance has isolated state. No manual cleanup of shared service state between instances.

inject() — the function that changed everything

Before Angular v14, injection was limited to constructor parameters. inject() changed that. You can now inject anywhere — service methods, factory functions, standalone functions used as guards or interceptors.

// Before: constructor injection only
@Injectable({ providedIn: 'root' })
export class TicketService {
  constructor(
    private http: HttpClient,
    private auth: AuthService
  ) {}
}

// Now: inject() anywhere
@Injectable({ providedIn: 'root' })
export class TicketService {
  private http = inject(HttpClient);
  private auth = inject(AuthService);
}

But the real power is in composition. You can write injection-based utility functions:

// A reusable function that sets up pagination — not a service, not a component
function withPagination(pageSize = 20) {
  const router = inject(Router);
  const route = inject(ActivatedRoute);

  const page = toSignal(
    route.queryParams.pipe(map(p => Number(p['page'] ?? 1)))
  );

  function goToPage(n: number) {
    router.navigate([], {
      queryParams: { page: n },
      queryParamsHandling: 'merge'
    });
  }

  return { page, pageSize, goToPage };
}

// Used inside any component's injection context
@Component({ ... })
export class TicketListComponent {
  pagination = withPagination(25);
}

This pattern is called a “functional injection token” or just a composition function. It’s how modern Angular composes behaviour without inheritance or large service classes.

InjectionToken for non-class dependencies

When you need to inject a value — a configuration object, a string, a function — InjectionToken is the right tool.

// Define the token
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');

export const FEATURE_FLAGS = new InjectionToken<FeatureFlags>('FEATURE_FLAGS', {
  providedIn: 'root',
  factory: () => ({
    newDashboard: false,
    betaReports: false,
  }),
});

// Provide it
bootstrapApplication(AppComponent, {
  providers: [{ provide: API_BASE_URL, useValue: environment.apiBaseUrl }],
});

// Inject it
@Injectable({ providedIn: 'root' })
export class ApiService {
  private baseUrl = inject(API_BASE_URL);
  private flags = inject(FEATURE_FLAGS);
}

No magic strings. Fully typed. Testable — you can provide a different value in tests without touching the service.

Tree-shakeable providers with factory functions

@Injectable({
  providedIn: 'root',
  useFactory: () => {
    const http = inject(HttpClient);
    const config = inject(APP_CONFIG);

    // Return a different implementation based on config
    return config.useMockApi
      ? new MockTicketService()
      : new RealTicketService(http);
  },
})
export class TicketService {}

The useFactory approach lets you make runtime decisions about which implementation to provide while keeping the service tree-shakeable.

Signals: The Modern Angular Reactivity Model

Angular v16 introduced signals. By v21 they’re central to how Angular applications should be written. Tutorials cover the basics — signal(), computed(), effect() — but the implications for architecture are deeper than most show.

Signals vs Observables — they’re not competing

The most common mistake I see when developers first encounter signals is treating them as a replacement for RxJS. They’re not. They solve different problems.

Signals are for synchronous state that components need to read. Observables are for asynchronous event streams — HTTP, WebSockets, time-based operations, complex transformation pipelines.

The bridge between them is toSignal() and toObservable():

@Component({ ... })
export class TicketDetailComponent {
  private ticketService = inject(TicketService);
  private route = inject(ActivatedRoute);

  // Observable of route param
  private ticketId$ = this.route.paramMap.pipe(
    map(params => params.get('id')!)
  );

  // Observable of ticket data — async, derived from route
  private ticket$ = this.ticketId$.pipe(
    switchMap(id => this.ticketService.getTicket(id))
  );

  // Signal — readable synchronously in the template
  ticket = toSignal(this.ticket$, { initialValue: null });

  // Computed signal — derived synchronously from another signal
  ticketTitle = computed(() => this.ticket()?.subject ?? 'Loading...');
  isResolved = computed(() => this.ticket()?.status === 'resolved');
}

The template reads ticket() and ticketTitle() synchronously — no async pipe needed, no null guards around Observable subscriptions. The async work happens in the Observable pipeline where it belongs. The signal layer is the clean reactive bridge to the template.

Signal inputs — the OnPush killer

Angular v17.1 introduced signal inputs. They replace @Input() and integrate directly with the signals reactivity system:

@Component({
  selector: 'app-ticket',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h2>{{ ticket().subject }}</h2>
    <span [class]="statusClass()">{{ ticket().status }}</span>
  `,
})
export class TicketComponent {
  // Signal input — reactive, typed, no decorator boilerplate
  ticket = input.required<Ticket>();
  showActions = input(true); // with default value

  // Computed from input — automatically updates when ticket changes
  statusClass = computed(() => `status--${this.ticket().status}`);
}

With signal inputs, OnPush components update correctly even without immutable data patterns — because the signal’s fine-grained reactivity tracks exactly which values changed and updates only what depends on them.

The effect() gotcha

effect() runs a side effect whenever its dependencies change. The most common mistake is using it to synchronise state — setting one signal from another inside an effect.

// Don't do this — creates a derived state via side effect
effect(() => {
  this.fullName.set(`${this.firstName()} ${this.lastName()}`);
});

// Do this instead — computed is the right tool for derived state
fullName = computed(() => `${this.firstName()} ${this.lastName()}`);

effect() is for actual side effects — writing to localStorage, calling an analytics service, updating a non-Angular third-party library when a signal changes. Not for deriving state. If you find yourself using effect() to set another signal, use computed() instead.

Output functions — v17+

The new output() function replaces @Output() with EventEmitter:

@Component({ ... })
export class TicketCardComponent {
  // Old way
  @Output() ticketSelected = new EventEmitter<Ticket>();

  // New way — cleaner, more explicit
  ticketSelected = output<Ticket>();
  ticketClosed = output<void>();

  select(ticket: Ticket) {
    this.ticketSelected.emit(ticket);
  }
}

Structural Directives: Writing Your Own

Most developers use *ngIf and *ngFor without ever writing a structural directive themselves. Understanding how they work — and knowing how to write one — unlocks a category of solution that tutorials never show.

How the asterisk syntax works

*ngIf="condition" is syntactic sugar for:

<ng-template [ngIf]="condition">
  <!-- content -->
</ng-template>

Angular desugars the asterisk syntax into a <ng-template> with the directive applied as an attribute binding. Understanding this makes structural directives less magical.

Writing a structural directive

Here’s a real one — a directive that renders content only when a feature flag is enabled:

@Directive({
  selector: '[appFeatureFlag]',
  standalone: true,
})
export class FeatureFlagDirective implements OnInit {
  private templateRef = inject(TemplateRef<unknown>);
  private viewContainer = inject(ViewContainerRef);
  private featureFlags = inject(FeatureFlagsService);

  @Input({ required: true }) appFeatureFlag!: string;
  @Input() appFeatureFlagElse?: TemplateRef<unknown>;

  ngOnInit() {
    if (this.featureFlags.isEnabled(this.featureFlag)) {
      this.viewContainer.createEmbeddedView(this.templateRef);
    } else if (this.appFeatureFlagElse) {
      this.viewContainer.createEmbeddedView(this.appFeatureFlagElse);
    }
  }
}

Usage:

<div *appFeatureFlag="'newDashboard'; else legacyDashboard">
  <app-new-dashboard />
</div>

<ng-template #legacyDashboard>
  <app-legacy-dashboard />
</ng-template>

TemplateRef is a reference to the <ng-template> content. ViewContainerRef is where you insert that content into the DOM. Those two together are all a structural directive is.

I use this pattern extensively for client-specific feature rollouts. The feature flag check happens at the template level. No *ngIf="featureFlags.isEnabled('x')" scattered throughout the templates.

Control Value Accessor: The Right Way to Build Custom Form Controls

ControlValueAccessor is the interface that connects a custom component to Angular’s ReactiveForms system. Almost every tutorial that covers it shows an example that looks correct but has subtle problems. Here is how to do it properly.

The interface

interface ControlValueAccessor {
  writeValue(value: any): void;
  registerOnChange(fn: any): void;
  registerOnTouched(fn: any): void;
  setDisabledState?(isDisabled: boolean): void;
}
  • writeValue — Angular calls this to push a value into your component
  • registerOnChange — Angular gives you a function to call when your component’s value changes
  • registerOnTouched — Angular gives you a function to call when your component is touched
  • setDisabledState — Angular calls this when the control is disabled or enabled

A complete implementation

@Component({
  selector: 'app-status-select',
  standalone: true,
  imports: [FormsModule],
  template: `
    <select
      [value]="value"
      [disabled]="disabled"
      (change)="onSelectChange($event)"
      (blur)="onTouched()"
    >
      <option *ngFor="let option of options" [value]="option.value">
        {{ option.label }}
      </option>
    </select>
  `,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => StatusSelectComponent),
      multi: true,
    },
  ],
})
export class StatusSelectComponent implements ControlValueAccessor {
  options = [
    { value: 'open', label: 'Open' },
    { value: 'in_progress', label: 'In Progress' },
    { value: 'resolved', label: 'Resolved' },
    { value: 'closed', label: 'Closed' },
  ];

  value: string = '';
  disabled = false;

  // These get set by Angular when the component connects to a form control
  private onChange: (value: string) => void = () => {};
  onTouched: () => void = () => {};

  // Angular → component: set the current value
  writeValue(value: string): void {
    this.value = value ?? '';
  }

  // Component → Angular: register the change callback
  registerOnChange(fn: (value: string) => void): void {
    this.onChange = fn;
  }

  // Component → Angular: register the touched callback
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }

  // Angular → component: handle disabled state
  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  // Internal: user selects a value
  onSelectChange(event: Event): void {
    const value = (event.target as HTMLSelectElement).value;
    this.value = value;
    this.onChange(value); // Tell Angular the value changed
  }
}

Usage in a form:

form = new FormGroup({
  status: new FormControl('open', Validators.required),
});
<form [formGroup]="form">
  <app-status-select formControlName="status" />
</form>

The most common mistakes I’ve corrected in code reviews:

  • Forgetting setDisabledState — the form control disables but the component doesn’t respond
  • Not initialising onChange and onTouched to no-op functions — causes errors if the component is used outside a form
  • Mutating the value in writeValue without triggering change detection in OnPush components — add this.cdRef.markForCheck() at the end of writeValue if using OnPush

Host Bindings and Host Listeners: The Clean Alternative

Most developers bind to the host element by putting attributes on the component selector in the template. @HostBinding and @HostListener — and their modern equivalent, the host metadata property — let you do this from inside the component or directive class.

Using the host metadata property (preferred in modern Angular)

@Component({
  selector: 'app-button',
  standalone: true,
  host: {
    // Static attribute binding
    role: 'button',

    // Class binding — bound to component property
    '[class.btn--loading]': 'isLoading',
    '[class.btn--disabled]': 'disabled',

    // Attribute binding
    '[attr.aria-busy]': 'isLoading',
    '[attr.aria-disabled]': 'disabled',
    '[attr.tabindex]': 'disabled ? -1 : 0',

    // Event listener on the host element
    '(click)': 'handleClick($event)',
    '(keydown.enter)': 'handleClick($event)',
    '(keydown.space)': 'handleClick($event)',
  },
  template: `
    <ng-content />
    <span *ngIf="isLoading" class="spinner" aria-hidden="true" />
  `,
})
export class ButtonComponent {
  @Input() disabled = false;
  @Input() isLoading = false;

  @Output() clicked = new EventEmitter<MouseEvent>();

  handleClick(event: MouseEvent) {
    if (!this.disabled && !this.isLoading) {
      this.clicked.emit(event);
    }
  }
}

This keeps the host element’s behaviour encapsulated in the component definition. The accessibility attributes are always correct. The disabled state is reflected on the host element automatically. Nothing leaks to the template consuming the component — they just use <app-button [disabled]="saving" [isLoading]="saving">.

Every interactive component managed its own ARIA attributes via host bindings. The consumer of the component library didn’t have to know about accessibility — it was built in.

Dynamic Components: ViewContainerRef and ComponentRef

Tutorials show you how to declare components statically in templates. Dynamic component creation — instantiating a component at runtime and inserting it into the DOM — is something most developers don’t encounter until they need it and then have to figure out from scratch.

@Component({
  selector: 'app-dialog-host',
  standalone: true,
  template: `<ng-container #dialogContainer />`,
})
export class DialogHostComponent {
  @ViewChild('dialogContainer', { read: ViewContainerRef })
  container!: ViewContainerRef;

  private componentRef?: ComponentRef<unknown>;

  open<T>(component: Type<T>, inputs?: Partial<T>): ComponentRef<T> {
    // Clear any existing dialog
    this.container.clear();

    // Create the component dynamically
    const ref = this.container.createComponent(component);

    // Set inputs programmatically
    if (inputs) {
      Object.entries(inputs).forEach(([key, value]) => {
        ref.setInput(key, value);
      });
    }

    this.componentRef = ref;
    return ref;
  }

  close() {
    this.container.clear();
    this.componentRef = undefined;
  }
}

The setInput() method (available from Angular v14) properly triggers change detection for OnPush components. Before it existed, you had to set instance.property directly and call markForCheck() manually — still works, but setInput() is cleaner.

Interceptors the Modern Way

HTTP interceptors in Angular v15+ are functions, not classes. The functional interceptor pattern is cleaner and easier to compose.

// auth.interceptor.ts
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const token = authService.getToken();

  if (!token) {
    return next(req);
  }

  const authReq = req.clone({
    headers: req.headers.set('Authorization', `Bearer ${token}`),
  });

  return next(authReq);
};

// error.interceptor.ts
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);
  const notificationService = inject(NotificationService);

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        router.navigate(['/login']);
      } else if (error.status === 403) {
        notificationService.show(
          'You do not have permission to perform this action.',
          'error'
        );
      } else if (error.status >= 500) {
        notificationService.show(
          'Something went wrong. Please try again.',
          'error'
        );
      }

      return throwError(() => error);
    })
  );
};

// Provide them
bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
  ],
});

Interceptors run in order. The auth interceptor adds the token. The error interceptor handles error responses. Each has a single responsibility and can be composed without class inheritance.

Router: The Features Most Developers Don’t Use

Route guards as functions

Like interceptors, guards are now functions. Cleaner, composable, injectable with inject():

export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);

  if (authService.isAuthenticated()) {
    return true;
  }

  return router.createUrlTree(['/login'], {
    queryParams: { returnUrl: state.url },
  });
};

export const roleGuard =
  (requiredRole: string): CanActivateFn =>
  () => {
    const authService = inject(AuthService);
    const router = inject(Router);

    if (authService.hasRole(requiredRole)) {
      return true;
    }

    return router.createUrlTree(['/forbidden']);
  };

The roleGuard is a factory — a function that returns a guard function, parameterised by the required role. You use it like this:

const routes: Routes = [
  {
    path: 'admin',
    canActivate: [authGuard, roleGuard('admin')],
    loadComponent: () =>
      import('./admin/admin.component').then((m) => m.AdminComponent),
  },
];

Route resolvers for data preloading

Resolvers run before the route activates, preloading the data the component needs. The component gets its data immediately on init rather than managing a loading state.

export const ticketResolver: ResolveFn<Ticket> = (route) => {
  const ticketService = inject(TicketService);
  const router = inject(Router);
  const id = route.paramMap.get('id')!;

  return ticketService.getTicket(id).pipe(
    catchError(() => {
      router.navigate(['/not-found']);
      return EMPTY;
    })
  );
};

// In routes
{
  path: 'tickets/:id',
  component: TicketDetailComponent,
  resolve: { ticket: ticketResolver }
}

// In the component
@Component({ ... })
export class TicketDetailComponent {
  private route = inject(ActivatedRoute);

  // Available immediately — no loading state needed
  ticket = toSignal(
    this.route.data.pipe(map(data => data['ticket'] as Ticket))
  );
}

Component input binding from router

Angular v16 introduced withComponentInputBinding(). Route parameters, query params, and resolver data are bound directly to component inputs — no ActivatedRoute injection needed:

// Setup
bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes, withComponentInputBinding())
  ]
});

// In the component — id comes directly from the route param
@Component({ ... })
export class TicketDetailComponent {
  // Bound from /tickets/:id
  id = input<string>();

  // Bound from resolver
  ticket = input<Ticket>();

  // Bound from query param ?filter=open
  filter = input<string>();
}

This is cleaner than ActivatedRoute for simple cases and integrates naturally with signal inputs.

Zone-less Angular: Running Without zone.js

Angular v18 introduced experimental zoneless support. Angular v19 made it stable enough for production use. This is the future of Angular and it’s worth understanding now.

Zone.js works by patching async operations globally. It has costs: patched operations are slower than native ones, the patch file itself is non-trivial in size, and zone.js causes confusing behaviour in some advanced scenarios (Web Workers, third-party libraries that interact poorly with patched APIs).

Zoneless Angular uses signals for reactivity instead. When a signal changes, Angular knows precisely which components depend on it and only checks those.

// Opt in to zoneless
bootstrapApplication(AppComponent, {
  providers: [
    provideExperimentalZonelessChangeDetection(), // v18-v19
    // provideZonelessChangeDetection() // v20+
  ],
});

For zoneless to work correctly, your components must use signals for their reactive state — or use markForCheck() on ChangeDetectorRef to notify Angular of changes that happen outside signals. Pure OnPush + signals works cleanly without zone.js.

The practical implication: applications built with signals from the start are already mostly ready for zoneless. Applications with heavy zone.js reliance — lots of Observable subscriptions with side effects directly mutating component state — need more migration work.

RxJS Patterns That Angular Developers Should Know

Angular’s async layer is RxJS. Knowing the right operators for the right situations is not just about elegance — it’s about correctness and preventing memory leaks.

takeUntilDestroyed — the clean subscription pattern

Before Angular v16, managing subscriptions required manual lifecycle management. The takeUntilDestroyed operator from @angular/core/rxjs-interop is the modern answer:

@Component({ ... })
export class TicketListComponent {
  private destroyRef = inject(DestroyRef);
  private ticketService = inject(TicketService);

  tickets: Ticket[] = [];

  ngOnInit() {
    this.ticketService.getTickets().pipe(
      takeUntilDestroyed(this.destroyRef)
    ).subscribe(tickets => {
      this.tickets = tickets;
    });
  }
}

When the component is destroyed, DestroyRef fires and the Observable unsubscribes automatically. No Subject, no manual ngOnDestroy, no forgetting to unsubscribe.

switchMap vs mergeMap vs concatMap vs exhaustMap

This is the one that causes the most subtle bugs. The choice of flattening operator controls what happens when a new inner Observable starts before the previous one completes.

// switchMap — cancels the previous inner Observable when a new one starts
// Use for: search input, route params, any "latest value wins" scenario
this.searchQuery$
  .pipe(
    debounceTime(300),
    switchMap((query) => this.searchService.search(query))
  )
  .subscribe((results) => (this.results = results));

// mergeMap — runs all inner Observables concurrently
// Use for: independent parallel operations where order doesn't matter
this.fileUploads$
  .pipe(mergeMap((file) => this.uploadService.upload(file)))
  .subscribe((result) => this.handleUploadResult(result));

// concatMap — queues inner Observables, runs them one at a time in order
// Use for: operations that must complete in sequence
this.saveActions$
  .pipe(concatMap((action) => this.saveService.save(action)))
  .subscribe();

// exhaustMap — ignores new values while an inner Observable is active
// Use for: form submissions (ignore clicks while a submission is in flight)
this.submitClicks$
  .pipe(exhaustMap(() => this.formService.submit(this.form.value)))
  .subscribe((response) => this.handleResponse(response));

In one of my projects, my peer was getting frustrated over an unexpected behavior in the code. I sat with him and found an exhaustMap being used for a search typeahead and a mergeMap being used for form submissions — both backwards. The typeahead was ignoring keystrokes after the first character; the form was allowing multiple simultaneous submissions. Two lines changed, two bugs fixed.

shareReplay — shared HTTP requests

When multiple components need the same HTTP response:

@Injectable({ providedIn: 'root' })
export class ReferenceDataService {
  private http = inject(HttpClient);

  // Executed once, result shared with all subscribers, replayed to late subscribers
  readonly countries$ = this.http
    .get<Country[]>('/api/countries')
    .pipe(shareReplay({ bufferSize: 1, refCount: true }));
}

shareReplay({ bufferSize: 1, refCount: true }) multicasts the HTTP request. The first subscriber triggers it. Subsequent subscribers get the cached response immediately. When all subscribers unsubscribe, the cache clears (refCount: true). Without refCount: true, the cache persists forever — usually not what you want for data that should refresh.

Standalone Components: The Modern Angular Architecture

From Angular v17, standalone components are the default. NgModules still work, but new Angular is written without them.

The practical differences beyond the standalone: true flag:

// Old: every component imported through NgModule
@NgModule({
  declarations: [TicketListComponent],
  imports: [CommonModule, ReactiveFormsModule, RouterModule],
  exports: [TicketListComponent],
})
export class TicketModule {}

// New: component declares its own dependencies directly
@Component({
  selector: 'app-ticket-list',
  standalone: true,
  imports: [
    CommonModule,
    ReactiveFormsModule,
    RouterLink,
    TicketCardComponent,
    StatusBadgeComponent,
    DatePipe,
  ],
  template: `...`,
})
export class TicketListComponent {}

The component is its own module. Its dependencies are explicit and local. Tree-shaking is more effective because the compiler can see exactly which components are used where.

For lazy loading in standalone applications:

const routes: Routes = [
  {
    path: 'tickets',
    // Lazily loaded standalone component
    loadComponent: () =>
      import('./tickets/ticket-list.component').then(
        (m) => m.TicketListComponent
      ),
  },
  {
    path: 'admin',
    // Lazily loaded route set
    loadChildren: () =>
      import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
  },
];

loadComponent for a single lazy component. loadChildren for a lazy subtree of routes. Both work without NgModules.

What Separates Good Angular Developers from Great Ones

After reviewing hundreds of pull requests and mentoring developers across multiple engagements, the patterns I consistently see in the strongest Angular developers are:

They understand the change detection tree. They know which strategy a component is using and why. They can look at a performance problem and trace it to change detection without needing profiler output to tell them where to look.

They understand the DI hierarchy. They know that providedIn: 'root' and providers: [ServiceClass] in a component are different things with different lifetimes and different implications, and they choose deliberately.

They treat RxJS operators as a vocabulary. They know when to use switchMap vs exhaustMap not because they’ve memorised a blog post about it but because they understand what each one does to the stream.

They build for the template consumer. They think about what it feels like to use their component from the outside — what inputs make sense, what outputs are needed, what accessibility behaviour should be built in rather than left to the consumer.

They keep up with the framework. Angular has moved significantly in the last few years. Signals, standalone components, functional guards, inject(), zoneless support — the developers who engage with these changes as they arrive are significantly more effective than the ones who learn a version of Angular and stop there.

The framework is not the same as it was in 2018, and the right way to write it is not the same either.

Conclusion

Angular has a reputation in some circles for being complex and verbose. Some of that reputation is earned from the NgModule era, where the boilerplate was real. Modern Angular — signals, standalone components, functional APIs, inject() — is a genuinely different experience.

The depth is still there. Change detection, the DI hierarchy, the RxJS operators, ControlValueAccessor, host bindings — these are not simple topics and the tutorials are right to skip them initially. But they’re the topics that determine whether your applications are performant, your components are reusable, and your forms behave correctly under edge cases.

The developers I’ve worked with who went past the tutorial surface and into these areas built noticeably better software. Not because the advanced features are always necessary — often they’re not. But because understanding them gives you a clear mental model of what Angular is doing on your behalf, and that model is what you rely on when something goes wrong.

Build things. Read the Angular source. Read the CHANGELOG on major versions — it tells you what the core team considers important. When a new API appears, understand what problem it’s solving before deciding whether to adopt it.

Angular rewards the developers who take it seriously.

Resources