· 5 min read

Angular Core Concepts: A Practical, No‑Fluff Guide

Components, templates, DI, services, RxJS, routing, forms, change detection, standalone APIs, signals, and testing — the mental model you actually use when building Angular apps.

Why Angular exists

Angular is a batteries‑included framework: routing, DI, forms, HTTP, and build tooling are first‑class. You describe UI with templates bound to component state, orchestrate side effects via DI‑powered services, and use RxJS for async composition.

Two key ideas:

  • Template ↔ component state binding
  • Dependency Injection (providers) to compose behavior and isolate side effects

Standalone components and NgModules (2023+)

Modern Angular favors standalone components, directives, and pipes. You import dependencies directly in the component.

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({ selector: 'app-hello', standalone: true, imports: [CommonModule], template: `<h1>Hello {{ name }}</h1>` })
export class HelloComponent {
  name = 'Angular';
}

NgModules still exist for legacy and library packaging, but prefer standalone for app code.

Templates, bindings, and directives

Angular templates support:

  • Interpolation: {{ expr }}
  • Property binding: [prop]="expr"
  • Event binding: (event)="handler($event)"
  • Two‑way binding: [(ngModel)]="value"

Structural directives (*ngIf, *ngFor, *ngSwitch) transform the DOM tree.

<button (click)="inc()">Clicked {{ count }}</button>
<ul>
  <li *ngFor="let item of items; trackBy: trackId">{{ item.label }}</li>
</ul>

Use trackBy to preserve identity across re-renders.

Change detection and signals

Angular uses a change detection tree. Historically you tuned it with OnPush and immutability; now Signals offer fine‑grained reactivity.

import { Component, signal, computed, effect } from '@angular/core';

@Component({ selector: 'app-counter', standalone: true, template: ` <button (click)="inc()">{{ count() }}</button> ` })
export class CounterComponent {
  readonly count = signal(0);
  readonly doubled = computed(() => this.count() * 2);
  readonly log = effect(() => console.log(this.count()));
  inc() {
    this.count.update((c) => c + 1);
  }
}

Prefer Signals for local state; keep OnPush for performance with input immutability.

Dependency Injection (providers)

Services encapsulate side effects and business logic. Provide them in the root or a feature boundary.

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface User {
  id: number;
  name: string;
}

@Injectable({ providedIn: 'root' })
export class UserService {
  private readonly http = inject(HttpClient);
  getUser(id: number): Observable<User> {
    return this.http.get<User>(`/api/users/${id}`);
  }
}

RxJS mental model (with Signals interop)

Observables represent async streams and excel at multi‑source composition, cancellation, and backpressure. Prefer cold observables exposed by services; in components, use the async pipe or convert to Signals at the boundary.

Template subscription via async pipe:

<ng-container *ngIf="user$ | async as user; else loading">
  <h2>{{ user.name }}</h2>
</ng-container>
<ng-template #loading>Loading…</ng-template>

Debounced search with cancellation (request‑latest‑wins):

import { Component, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap, catchError, of } from 'rxjs';

@Component({
  selector: 'app-search',
  standalone: true,
  template: `
    <input [value]="q()" (input)="q.set(($event.target as HTMLInputElement).value)" />
    <pre>{{ results() | json }}</pre>
  `,
})
export class SearchComponent {
  q = signal('');
  results = toSignal(
    toObservable(this.q).pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap((q) => (q ? this.api.search(q) : of([]))),
      catchError(() => of([]))
    ),
    { initialValue: [] as unknown[] }
  );
  constructor(private readonly api: { search(q: string): unknown }) {}
}

Combine streams into a view‑model:

import { combineLatest, map } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';

vm = toSignal(combineLatest([this.user$, this.settings$]).pipe(map(([u, s]) => ({ name: u.name, theme: s.theme }))), {
  initialValue: { name: '', theme: 'light' },
});

Retry with exponential backoff:

import { retry, timer } from 'rxjs';

this.data$ = this.http.get('/api/data').pipe(retry({ count: 3, delay: (_e, i) => timer(2 ** i * 250) }));

Rules of thumb:

  • Keep RxJS in services; convert to Signals at component boundaries.
  • switchMap (latest‑wins), mergeMap (parallel), exhaustMap (ignore while busy).
  • Prefer async pipe or toSignal over manual subscribe/unsubscribe.

Routing (standalone)

Define routes with standalone APIs; lazy‑load features for perf.

import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', loadComponent: () => import('./home.component').then((m) => m.HomeComponent) },
  { path: 'users/:id', loadComponent: () => import('./user.component').then((m) => m.UserComponent) },
];

Forms: template‑driven vs reactive

Reactive forms scale better and compose with RxJS.

import { Component } from '@angular/core';
import { ReactiveFormsModule, FormControl, Validators } from '@angular/forms';

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form (ngSubmit)="submit()">
      <input [formControl]="email" placeholder="Email" />
      <button [disabled]="email.invalid">Submit</button>
    </form>
  `,
})
export class LoginComponent {
  email = new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] });
  submit() {
    /* ... */
  }
}

Inputs, outputs, and content projection (Signals API)

Prefer the function APIs input(), output(), and model() which integrate with Signals and offer better typing. Decorators remain supported but aren’t the recommended default.

import { Component, input, output, model } from '@angular/core';

@Component({
  selector: 'app-modal',
  standalone: true,
  template: `
    <div class="backdrop" (click)="close.emit()"></div>
    <div class="modal"><ng-content /></div>
  `,
})
export class ModalComponent {
  open = input(false); // call as open() in template
  close = output<void>();
}

@Component({
  selector: 'app-text-field',
  standalone: true,
  template: ` <input [value]="value()" (input)="value.set(($event.target as HTMLInputElement).value)" /> `,
})
export class TextFieldComponent {
  value = model<string>(''); // supports [(value)] from parent
}

HttpClient and interceptors

Centralize cross‑cutting concerns (auth, logging, retries) with interceptors.

import { HttpInterceptorFn } from '@angular/common/http';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = localStorage.getItem('token');
  return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};

Register via provideHttpClient with interceptors in your app bootstrap.

Testing strategy

  • Component tests with TestBed or standalone bootstrapApplication
  • Service tests with injected fakes/mocks
  • Prefer harnesses for Material components
import { TestBed } from '@angular/core/testing';
import { HelloComponent } from './hello.component';

describe('HelloComponent', () => {
  it('renders name', async () => {
    await TestBed.configureTestingModule({ imports: [HelloComponent] }).compileComponents();
    const fixture = TestBed.createComponent(HelloComponent);
    fixture.componentInstance.name = 'World';
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('World');
  });
});

Performance checklist

  • Use Signals or OnPush and immutable inputs
  • TrackBy in *ngFor
  • Lazy‑load routes and components
  • Push side effects into services; keep components lean
  • Prefer async pipe over manual subscriptions

TL;DR mental model

  • Components render via templates bound to reactive state
  • DI provides services for side effects and composition
  • RxJS models async; use the template async pipe
  • Standalone APIs simplify structure; signals enable fine‑grained updates
Back to Blog