· 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
asyncpipe ortoSignalover 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
OnPushand immutable inputs - TrackBy in
*ngFor - Lazy‑load routes and components
- Push side effects into services; keep components lean
- Prefer
asyncpipe 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
asyncpipe - Standalone APIs simplify structure; signals enable fine‑grained updates