Chapter 11: State Management in Angular
Every application needs to manage state — the data that drives what users see on the screen.
Examples of state:
- A shopping cart’s contents.
- A logged-in user’s profile.
- A list of posts fetched from an API.
As apps grow, managing state becomes more complex. Angular provides several options, ranging from simple local state in components to enterprise-scale solutions like NgRx.
11.1 Local State
The simplest way to manage state is inside a component.
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Count: {{ count }}</p>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = 0;
increment() {
this.count++;
}
}
✅ Works fine for isolated components.
❌ Breaks down when multiple components need the same state.
11.2 Parent → Child
Use @Input() on the child to accept data from the parent.
Child (profile-card.component.ts)
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-profile-card',
standalone: true,
template: `
<article>
<h3>{{ name }}</h3>
<p *ngIf="bio">{{ bio }}</p>
</article>
`
})
export class ProfileCardComponent {
@Input() name = 'Anonymous';
@Input() bio?: string;
}
Parent (team.component.ts)
import { Component } from '@angular/core';
import { ProfileCardComponent } from './profile-card.component';
@Component({
selector: 'app-team',
standalone: true,
imports: [ProfileCardComponent],
template: `
<app-profile-card
[name]="leadName"
[bio]="leadBio">
</app-profile-card>
`
})
export class TeamComponent {
leadName = 'Aisha Khan';
leadBio = 'Frontend lead focused on design systems.';
}
Key idea: data flows down from parent to child.
11.3 Sibling ↔ Sibling
Components can’t pass inputs directly to siblings. The classic pattern is:
- Sibling A emits an event (e.g., a selected value)
- Parent handles the event and updates state
- Parent passes updated state down to Sibling B via
@Input()
Sibling A: selects a color (color-picker.component.ts)
import { Component, EventEmitter, Output } from '@angular/core';
@Component({
selector: 'app-color-picker',
standalone: true,
template: `
<label>Pick a color:</label>
<button (click)="choose('red')">Red</button>
<button (click)="choose('green')">Green</button>
<button (click)="choose('blue')">Blue</button>
`
})
export class ColorPickerComponent {
@Output() colorChange = new EventEmitter<string>();
choose(color: string) { this.colorChange.emit(color); }
}
Sibling B: displays the color (color-preview.component.ts)
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-color-preview',
standalone: true,
template: `
<div
style="width:120px;height:60px;border:1px solid #ccc;"
[style.background]="color">
</div>
<p>Selected: {{ color || 'none' }}</p>
`
})
export class ColorPreviewComponent {
@Input() color = '';
}
Parent: mediates between siblings (color-playground.component.ts)
import { Component } from '@angular/core';
import { ColorPickerComponent } from './color-picker.component';
import { ColorPreviewComponent } from './color-preview.component';
@Component({
selector: 'app-color-playground',
standalone: true,
imports: [ColorPickerComponent, ColorPreviewComponent],
template: `
<app-color-picker (colorChange)="onColor($event)"></app-color-picker>
<app-color-preview [color]="selectedColor"></app-color-preview>
`
})
export class ColorPlaygroundComponent {
selectedColor = '';
onColor(color: string) { this.selectedColor = color; }
}
Why this matters: This “lift state up” pattern keeps siblings decoupled and predictable.
Tip: For many cross-sibling interactions across distant branches, prefer a shared service with signals or a global store (NgRx) instead of chaining many parent mediators.
11.5 Using @ViewChild()
@ViewChild() lets a parent query a child (component, directive, or native element) that exists in its own template. Common uses:
- Call a public method on the child component
- Focus an input (access native element)
- Read internal child state (read-only preferred)
A) Call a child component method
Child (modal.component.ts)
import { Component } from '@angular/core';
@Component({
selector: 'app-modal',
standalone: true,
template: `
<dialog #dlg>
<h3>Modal</h3>
<p>Hi there!</p>
<button (click)="close()">Close</button>
</dialog>
`
})
export class ModalComponent {
private dialogEl!: HTMLDialogElement;
// Angular calls after view init; link the native element
ngAfterViewInit() {
this.dialogEl = (document.querySelector('app-modal dialog') as HTMLDialogElement);
}
open() { this.dialogEl?.showModal(); }
close() { this.dialogEl?.close(); }
}
Parent uses @ViewChild to drive it (page.component.ts)
import { Component, ViewChild, AfterViewInit } from '@angular/core';
import { ModalComponent } from './modal.component';
@Component({
selector: 'app-page',
standalone: true,
imports: [ModalComponent],
template: `
<button (click)="open()">Open Modal</button>
<app-modal></app-modal>
`
})
export class PageComponent implements AfterViewInit {
@ViewChild(ModalComponent) modal?: ModalComponent;
ngAfterViewInit() {
// modal is available here
}
open() {
this.modal?.open();
}
}
Note: In real apps, it’s cleaner to reference the dialog with a
@ViewChild('dlg')template ref rather thanquerySelector; shown here for clarity. See the native element example below.
B) Focus an input via native element
Template reference + ElementRef
import { Component, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-search',
standalone: true,
template: `
<input #term type="text" placeholder="Search…" />
<button (click)="focus()">Focus</button>
`
})
export class SearchComponent implements AfterViewInit {
@ViewChild('term') termInput?: ElementRef<HTMLInputElement>;
ngAfterViewInit() {
// Optionally autofocus when ready:
this.termInput?.nativeElement.focus();
}
focus() {
this.termInput?.nativeElement.focus();
}
}
Key points
@ViewChild()resolves after the view is initialized → usengAfterViewInit.- Prefer calling public methods on a child rather than poking internal fields.
- For DOM, access via
ElementRefand avoid direct DOM manipulation when possible (use Angular bindings). - If the element is created conditionally (
*ngIf), set@ViewChild(..., { static: false })(default) and ensure it exists before using it.
When to use which?
@Input()(parent → child): pass data down. Simple and explicit.- Sibling ↔ Sibling via Parent: raise an event from one sibling, update state in parent, pass down to the other via
@Input().- For many siblings/features → consider a shared service with signals or NgRx.
@ViewChild()(parent → child instance): call public methods (e.g., open modal, reset form) or interact with native elements (focus). Use sparingly to avoid tight coupling.
11.6 Service-Based State (Shared Services)
The most common Angular pattern is to use services with DI to share state.
@Injectable({ providedIn: 'root' })
export class CartService {
private items: string[] = [];
addItem(item: string) { this.items.push(item); }
getItems() { return this.items; }
}
Injected into components:
@Component({
selector: 'app-cart',
standalone: true,
template: `
<button (click)="add()">Add Item</button>
<ul><li *ngFor="let item of items">{{ item }}</li></ul>
`
})
export class CartComponent {
items: string[] = [];
constructor(private cart: CartService) {
this.items = this.cart.getItems();
}
add() { this.cart.addItem('Book'); }
}
✅ Simple, effective, easy to test.
❌ State changes don’t automatically notify components (unless combined with Observables or Signals).
11.7 State with RxJS Observables
Before signals, the main reactive pattern was BehaviorSubject or ReplaySubject.
@Injectable({ providedIn: 'root' })
export class UserService {
private userSubject = new BehaviorSubject<User | null>(null);
user$ = this.userSubject.asObservable();
setUser(user: User) { this.userSubject.next(user); }
}
Component:
@Component({
selector: 'app-profile',
standalone: true,
template: `<p *ngIf="user$ | async as user">Welcome, {{ user.name }}</p>`
})
export class ProfileComponent {
user$ = this.userService.user$;
constructor(private userService: UserService) {}
}
✅ Very powerful, integrates with Angular’s async pipe.
❌ More boilerplate and learning curve than signals.
11.8 @Input() with Signals
Normally, @Input() is just a property that Angular assigns when the parent updates it. But with Angular’s signal inputs, you can turn inputs into reactive signals that auto-update when the parent changes.
Example 1: Simple Input Signal
import { Component, input } from '@angular/core';
@Component({
selector: 'app-greeting',
standalone: true,
template: `<h2>Hello, {{ name() }}!</h2>`
})
export class GreetingComponent {
// input() makes a signal input
name = input<string>('Guest');
}
Parent component:
@Component({
selector: 'app-home',
standalone: true,
imports: [GreetingComponent],
template: `
<app-greeting [name]="userName"></app-greeting>
<button (click)="userName = 'Alice'">Set Name</button>
`
})
export class HomeComponent {
userName = 'Bob';
}
- Initially renders: Hello, Bob!
- After clicking button: Hello, Alice!
- If no
nameinput is provided, defaults to Guest.
Example 2: Computed Signal from an Input
You can derive values from inputs using computed().
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-user-card',
standalone: true,
template: `
<h3>{{ displayName() }}</h3>
`
})
export class UserCardComponent {
firstName = input<string>();
lastName = input<string>();
displayName = computed(() =>
`${this.firstName() ?? ''} ${this.lastName() ?? ''}`.trim()
);
}
Parent component:
<app-user-card [firstName]="'Jane'" [lastName]="'Doe'"></app-user-card>
Renders: Jane Doe.
Example 3: Input Signals with Defaults
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Start: {{ start() }}</p>
<p>Count: {{ count }}</p>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
start = input<number>(0); // default value
count = 0;
increment() {
this.count++;
}
}
If parent provides [start]="10", it shows Start: 10. If not, defaults to 0.
Key Benefits of Input Signals
- ✅ Reactive by default — no need for
ngOnChanges(). - ✅ Can be combined with computed signals.
- ✅ Can provide default values.
- ✅ Cleaner and more ergonomic than traditional
@Input().
11.9 NgRx: Enterprise-Scale State Management
For large applications, Angular offers NgRx, inspired by Redux. NgRx uses:
- Store – a single global state tree.
- Actions – events that describe state changes.
- Reducers – pure functions that update state.
- Effects – handle side effects (API calls).
Example: Counter with NgRx
Action:
export const increment = createAction('[Counter] Increment');
Reducer:
export const counterReducer = createReducer(0,
on(increment, state => state + 1)
);
Component:
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Count: {{ count$ | async }}</p>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count$ = this.store.select('count');
constructor(private store: Store<{ count: number }>) {}
increment() { this.store.dispatch(increment()); }
}
✅ Scales to very large apps.
✅ Predictable and testable.
❌ Steeper learning curve and boilerplate-heavy for small apps.
11.7 Choosing the Right Approach
| Approach | Best For |
|---|---|
| Local Component State | Simple, isolated UI state |
| @Input/@Output | Parent-child communication |
| Service (Plain) | Shared logic/data between few components |
| Service + Signals | Medium apps needing reactivity |
| RxJS Observables | Complex async streams, multi-subscriber |
| NgRx | Enterprise-scale apps with global state |
11.8 Best Practices
- Start simple (component or service).
- Introduce signals for reactivity instead of manually wiring RxJS everywhere.
- Use RxJS when you truly need streams or complex async handling.
- Adopt NgRx only when your app grows beyond what services/signals can handle.
11.9 Summary
- State management ranges from local variables to enterprise libraries.
- Signals bring fine-grained reactivity to Angular apps.
- NgRx is still the go-to for large-scale, complex apps.
- Always choose the simplest tool that meets your needs.