Socialize

Showing posts with label Angular best practices. Show all posts
Showing posts with label Angular best practices. Show all posts

Thursday, September 25, 2025

How to achieve state management in Angular — a complete guide with examples

 State is the data that drives your UI: user input, server data, UI selections, etc. In Angular apps — especially as they grow — managing that state so the UI stays predictable, testable, and fast becomes essential.

This article walks through practical approaches to state management in Angular, shows complete code examples (simple and advanced), and gives recommendations for which approach to use for different scenarios.

Table of contents

  1. What is application state and why manage it?

  2. Approaches at a glance

  3. Local component state (when it's enough)

  4. Parent–child communication with @Input / @Output

  5. Shared services + RxJS (recommended for small–medium apps) — complete todo example

  6. NgRx (Redux-style) for large apps — complete todo example (actions, reducer, selectors, effects)

  7. Comparison: choose the right approach

  8. Best practices & performance tips

  9. Testing tips

  10. Migration notes & closing


1 — What is application state and why manage it?

State = any data your UI depends on (lists, selected item, user session, forms, loading flags, etc.).
Why manage it explicitly?

  • Prevent inconsistent UI (two components disagreeing on the same data).

  • Make the app predictable and easier to debug/test.

  • Improve performance by avoiding unnecessary re-rendering.

  • Keep code maintainable as the app grows.


2 — Approaches at a glance

  • Local component state: simplest; use when state doesn't leave the component.

  • @Input / @Output: parent-child communication for small hierarchies.

  • Shared services + RxJS (BehaviorSubject / ReplaySubject): lightweight reactive store — great for many apps.

  • NgRx (Redux pattern): single immutable store with actions/reducers/selectors/effects — best for complex enterprise apps.

  • Other libraries: NGXS, Akita, or NgRx Component Store (for local feature stores).


3 — Local component state (when it's enough)

If your state only lives in one component, keep it there — simple and fast.

@Component({ selector: 'app-counter', template: ` < p > Count: { { count } }</ p > < button(click) = "increment()" > +</ button > ` }) export class CounterComponent { count = 0; increment() { this.count++; } }

Use this only when data doesn't need to be shared.


4 — Parent–child via @Input / @Output

Good for simple data flow between parent/child components. Avoid using this to pass data across many nesting levels.

// child.component.ts @Input() name!: string; @Output() saved = new EventEmitter<string>();

5 — Shared services + RxJS — the practical, reactive approach

When to use: medium apps, multiple components need the same data, or you want a reactive model without the complexity of NgRx.

Key idea

Create a singleton service that holds the state as RxJS streams (e.g., BehaviorSubject) and expose Observables to components. Components subscribe (usually via async pipe) and call service methods to update the state.

Example: Todo app using BehaviorSubject

Model

// todo.model.ts export interface Todo { id: number; title: string; completed: boolean; }

Service

// todo.service.ts import { Injectable } from '@angular/core'; import { BehaviorSubject, of } from 'rxjs'; import { delay } from 'rxjs/operators'; import { Todo } from './todo.model'; @Injectable({ providedIn: 'root' }) export class TodoService { private todosSubject = new BehaviorSubject<Todo[]>([]); readonly todos$ = this.todosSubject.asObservable(); private idCounter = 1; // Add a new todo add(title: string) { const newTodo: Todo = { id: this.idCounter++, title, completed: false }; this.todosSubject.next([...this.todosSubject.value, newTodo]); } // Toggle completed toggle(id: number) { const updated = this.todosSubject.value.map(t => t.id === id ? { ...t, completed: !t.completed } : t ); this.todosSubject.next(updated); } // Remove remove(id: number) { this.todosSubject.next(this.todosSubject.value.filter(t => t.id !== id)); } // Simulate loading from an API loadFromServer() { of<Todo[]>([ { id: 101, title: 'Use Angular', completed: false }, { id: 102, title: 'Write blog', completed: true } ]) .pipe(delay(800)) .subscribe(data => this.todosSubject.next(data)); } }

Component (presentation + binding)

// todo-list.component.ts import { Component, ChangeDetectionStrategy } from '@angular/core'; import { TodoService } from './todo.service'; @Component({ selector: 'app-todo-list', templateUrl: './todo-list.component.html', changeDetection: ChangeDetectionStrategy.OnPush // important for perf }) export class TodoListComponent { todos$ = this.todoService.todos$; newTitle = ''; constructor(private todoService: TodoService) {} add() { const title = this.newTitle.trim(); if (!title) return; this.todoService.add(title); this.newTitle = ''; } toggle(id: number) { this.todoService.toggle(id); } remove(id: number) { this.todoService.remove(id); } load() { this.todoService.loadFromServer(); } }

Template

<!-- todo-list.component.html --> <div> <input [(ngModel)]="newTitle" placeholder="New todo" /> <button (click)="add()">Add</button> <button (click)="load()">Load sample</button> </div> <ul> <li *ngFor="let todo of todos$ | async; trackBy: trackById"> <label> <input type="checkbox" [checked]="todo.completed" (change)="toggle(todo.id)" /> <span [class.completed]="todo.completed">{{ todo.title }}</span> </label> <button (click)="remove(todo.id)">Delete</button> </li> </ul>

Performance tips:

  • Use ChangeDetectionStrategy.OnPush.

  • Use trackBy in *ngFor.

  • Expose Observables and consume via async pipe — avoids manual subscriptions/unsubscriptions.

This pattern scales well for many apps, is easy to test, and keeps code readable.


6 — NgRx (Redux pattern) — for large, complex apps

When to use: large enterprise apps, many teams, complex UI flows, need time-travel debugging, want single source of truth.

NgRx implements a Redux-style architecture:

  • Actions — describe events (user or server).

  • Reducer — pure function that produces new state from previous state + action.

  • Store — holds the app state tree.

  • Selectors — memoized queries into the store.

  • Effects — handle side effects (HTTP, other async operations).

Below is a compact but full NgRx example for the same Todo functionality.

Actions

// todo.actions.ts import { createAction, props } from '@ngrx/store'; import { Todo } from './todo.model'; export const loadTodos = createAction('[Todo] Load Todos'); export const loadTodosSuccess = createAction('[Todo] Load Todos Success', props<{ todos: Todo[] }>()); export const loadTodosFailure = createAction('[Todo] Load Todos Failure', props<{ error: any }>()); export const addTodo = createAction('[Todo] Add', props<{ title: string }>()); export const toggleTodo = createAction('[Todo] Toggle', props<{ id: number }>()); export const removeTodo = createAction('[Todo] Remove', props<{ id: number }>());

State & Reducer

// todo.reducer.ts import { createReducer, on } from '@ngrx/store'; import * as TodoActions from './todo.actions'; import { Todo } from './todo.model'; export interface TodoState { todos: Todo[]; loading: boolean; error: string | null; } export const initialState: TodoState = { todos: [], loading: false, error: null }; export const todoReducer = createReducer( initialState, on(TodoActions.loadTodos, state => ({ ...state, loading: true })), on(TodoActions.loadTodosSuccess, (state, { todos }) => ({ ...state, loading: false, todos })), on(TodoActions.loadTodosFailure, (state, { error }) => ({ ...state, loading: false, error })), on(TodoActions.addTodo, (state, { title }) => { const newTodo: Todo = { id: Date.now(), title, completed: false }; return { ...state, todos: [...state.todos, newTodo] }; }), on(TodoActions.toggleTodo, (state, { id }) => ({ ...state, todos: state.todos.map(t => (t.id === id ? { ...t, completed: !t.completed } : t)) })), on(TodoActions.removeTodo, (state, { id }) => ({ ...state, todos: state.todos.filter(t => t.id !== id) })) );

Selectors

// todo.selectors.ts import { createFeatureSelector, createSelector } from '@ngrx/store'; import { TodoState } from './todo.reducer'; const selectTodoFeature = createFeatureSelector<TodoState>('todos'); export const selectAllTodos = createSelector(selectTodoFeature, s => s.todos); export const selectLoading = createSelector(selectTodoFeature, s => s.loading); export const selectError = createSelector(selectTodoFeature, s => s.error);

Effects (handle async loading)

// todo.effects.ts import { Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import * as TodoActions from './todo.actions'; import { HttpClient } from '@angular/common/http'; import { catchError, map, mergeMap, of } from 'rxjs'; @Injectable() export class TodoEffects { loadTodos$ = createEffect(() => this.actions$.pipe( ofType(TodoActions.loadTodos), mergeMap(() => this.http.get<any[]>('/api/todos').pipe( map(todos => TodoActions.loadTodosSuccess({ todos })), catchError(error => of(TodoActions.loadTodosFailure({ error }))) ) ) ) ); constructor(private actions$: Actions, private http: HttpClient) {} }

Module setup

// app.module.ts (imports snippet) import { StoreModule } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; import { todoReducer } from './state/todo.reducer'; import { TodoEffects } from './state/todo.effects'; import { StoreDevtoolsModule } from '@ngrx/store-devtools'; @NgModule({ imports: [ // ... StoreModule.forRoot({ todos: todoReducer }), EffectsModule.forRoot([TodoEffects]), StoreDevtoolsModule.instrument({ maxAge: 25 }) // optional, for debugging ], // ... }) export class AppModule {}

Component using the store

// todo-list.component.ts (NgRx version) export class TodoListComponent { todos$ = this.store.select(selectAllTodos); loading$ = this.store.select(selectLoading); constructor(private store: Store) {} ngOnInit() { this.store.dispatch(loadTodos()); } add(title: string) { this.store.dispatch(addTodo({ title })); } toggle(id: number) { this.store.dispatch(toggleTodo({ id })); } remove(id: number) { this.store.dispatch(removeTodo({ id })); } }

Notes:

  • NgRx encourages immutability and pure reducers.

  • Use selectors to memoize and compute derived data.

  • Effects are the recommended place for network calls and side effects.

  • NgRx has optional helpers like createEntityAdapter() for normalized collections.


7 — Comparison: which approach when?

ScenarioRecommended approach
Small component-only stateLocal component state
Parent-child simple sharing@Input / @Output
Many components, app-level shared state (small–medium)Service + RxJS (BehaviorSubject)
Complex app, many features, many teams, need strict patterns & devtoolsNgRx (or NGXS / Akita)
Feature-local store inside a component treeNgRx Component Store or Akita feature stores

8 — Best practices & performance tips

  • Prefer streams: Expose Observables from services and components; consume with async pipe.

  • OnPush change detection: Use for UI components showing Observable data.

  • Avoid storing derived state: Compute derived values with selectors or pipes.

  • Normalize big collections: Use entity adapters or normalized shapes (by id) to keep updates cheap.

  • Use trackBy with *ngFor.

  • Keep reducers pure and avoid side effects in reducers (use effects/services).

  • Lazy load feature state in NgRx to reduce initial bundle size.

  • Use devtools when using NgRx (@ngrx/store-devtools) for time-travel debugging.

  • Memoize selectors to avoid unnecessary recomputation.


9 — Testing tips

  • Services + BehaviorSubject: Test the service by subscribing to todos$ and asserting emissions after calling add()/remove()/toggle(). No need to mock Angular DI — the service is plain TS with RxJS.

  • NgRx:

    • Test reducers as pure functions (send action, assert new state).

    • Test effects with provideMockActions and mock HTTP calls.

    • Use provideMockStore for component unit tests to mock store selects and dispatches.

Example reducer test (Jasmine):

it('should add todo on addTodo action', () => { const initial = { todos: [], loading: false, error: null }; const action = addTodo({ title: 'New' }); const result = todoReducer(initial, action); expect(result.todos.length).toBe(1); expect(result.todos[0].title).toBe('New'); });

10 — Migration notes & closing

  • Start simple: adopt services + RxJS. Move to NgRx only when complexity/teams justify it.

  • Hybrid approach: many apps use a combination — NgRx for global domain state and services/component-store for local UI state.

  • Refactor incrementally: extract a feature into NgRx gradually (actions/reducer/selectors) instead of a big rewrite.


Final checklist for implementing state in Angular

  • Decide scope: local vs shared vs global.

  • Prefer immutable updates (spread operator).

  • Use Observables + async pipe.

  • Optimize change detection with OnPush.

  • Add unit tests for services, reducers, and effects.

  • For large apps, use NgRx (and devtools); for smaller apps, prefer services + RxJS.



Blog Archive

Don't Copy

Protected by Copyscape Online Plagiarism Checker

Pages