Angular State Management: A Complete Guide to Different Approaches
State management is a crucial aspect of modern Angular applications. As applications grow in complexity, managing state effectively becomes increasingly important. In this comprehensive guide, we'll explore different state management options in Angular, from simple services to sophisticated solutions like NgRx, including the new Signals feature.
Introduction to State Management
State management refers to how we handle and maintain data across components in our application. This data could include:
- User information
- Application settings
- Form data
- API responses
- UI state (loading indicators, toggle states)
Let's dive into different approaches, starting from the simplest to more complex solutions.
1. Simple Service-Based State Management
The most basic approach to state management in Angular uses services with observables.
Implementation Example
First, let's create a basic state service:
// user-state.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
interface User {
id: number;
name: string;
email: string;
}
@Injectable({
providedIn: 'root'
})
export class UserStateService {
private userSubject = new BehaviorSubject<User | null>(null);
user$ = this.userSubject.asObservable();
setUser(user: User) {
this.userSubject.next(user);
}
clearUser() {
this.userSubject.next(null);
}
getCurrentUser(): User | null {
return this.userSubject.getValue();
}
}
Using the service in a component:
// user-profile.component.ts
import { Component, OnInit } from '@angular/core';
import { UserStateService } from './user-state.service';
@Component({
selector: 'app-user-profile',
template: `
<div *ngIf="user$ | async as user">
<h2>Welcome, {{ user.name }}!</h2>
<p>Email: {{ user.email }}</p>
</div>
`
})
export class UserProfileComponent implements OnInit {
user$ = this.userState.user$;
constructor(private userState: UserStateService) {}
ngOnInit() {
// Simulating user login
this.userState.setUser({
id: 1,
name: 'John Doe',
email: 'john@example.com'
});
}
}
2. Angular Signals: The New State Management Approach
Signals, introduced in Angular 16+, provide a new way to handle reactive state management.
Basic Signals Implementation
// counter.service.ts
import { Injectable, signal, computed } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class CounterService {
// Create a signal with initial value
private count = signal(0);
// Create a computed signal
doubleCount = computed(() => this.count() * 2);
increment() {
// Update signal value
this.count.update(current => current + 1);
}
decrement() {
this.count.update(current => current - 1);
}
getCount() {
return this.count;
}
}
Using Signals in a component:
// counter.component.ts
import { Component } from '@angular/core';
import { CounterService } from './counter.service';
@Component({
selector: 'app-counter',
template: `
<div>
<h2>Counter: {{ counterService.getCount()() }}</h2>
<h3>Double Count: {{ counterService.doubleCount() }}</h3>
<button (click)="counterService.increment()">Increment</button>
<button (click)="counterService.decrement()">Decrement</button>
</div>
`
})
export class CounterComponent {
constructor(public counterService: CounterService) {}
}
Advanced Signals Example with API Integration
// todo.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface Todo {
id: number;
title: string;
completed: boolean;
}
@Injectable({
providedIn: 'root'
})
export class TodoService {
private todos = signal<Todo[]>([]);
private loading = signal(false);
// Computed signals
completedTodos = computed(() =>
this.todos().filter(todo => todo.completed)
);
pendingTodos = computed(() =>
this.todos().filter(todo => !todo.completed)
);
constructor(private http: HttpClient) {}
async fetchTodos() {
this.loading.set(true);
try {
const response = await fetch('https://api.example.com/todos');
const data = await response.json();
this.todos.set(data);
} finally {
this.loading.set(false);
}
}
addTodo(title: string) {
const newTodo: Todo = {
id: Date.now(),
title,
completed: false
};
this.todos.update(current => [...current, newTodo]);
}
toggleTodo(id: number) {
this.todos.update(todos =>
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}
}
3. NgRx: Enterprise-Level State Management
NgRx provides a robust state management solution based on Redux principles.
Setting up NgRx
First, install NgRx:
npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools
Implementation Example
// todo.actions.ts
import { createAction, props } from '@ngrx/store';
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 }>()
);
// todo.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as TodoActions from './todo.actions';
import { EntityState, createEntityAdapter } from '@ngrx/entity';
export interface TodoState extends EntityState<Todo> {
loading: boolean;
error: any;
}
export const todoAdapter = createEntityAdapter<Todo>();
export const initialState: TodoState = todoAdapter.getInitialState({
loading: false,
error: null
});
export const todoReducer = createReducer(
initialState,
on(TodoActions.loadTodos, state => ({
...state,
loading: true
})),
on(TodoActions.loadTodosSuccess, (state, { todos }) =>
todoAdapter.setAll(todos, { ...state, loading: false })
),
on(TodoActions.loadTodosFailure, (state, { error }) => ({
...state,
error,
loading: false
}))
);
// todo.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError } from 'rxjs/operators';
import * as TodoActions from './todo.actions';
@Injectable()
export class TodoEffects {
loadTodos$ = createEffect(() =>
this.actions$.pipe(
ofType(TodoActions.loadTodos),
mergeMap(() =>
this.todoService.getTodos().pipe(
map(todos => TodoActions.loadTodosSuccess({ todos })),
catchError(error => of(TodoActions.loadTodosFailure({ error })))
)
)
)
);
constructor(
private actions$: Actions,
private todoService: TodoService
) {}
}
// todo.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { TodoState, todoAdapter } from './todo.reducer';
export const selectTodoState = createFeatureSelector<TodoState>('todos');
export const {
selectAll: selectAllTodos,
selectEntities: selectTodoEntities,
selectIds: selectTodoIds,
} = todoAdapter.getSelectors(selectTodoState);
export const selectLoading = createSelector(
selectTodoState,
state => state.loading
);
export const selectError = createSelector(
selectTodoState,
state => state.error
);
Using NgRx in a component:
// todo-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import * as TodoActions from './store/todo.actions';
import * as TodoSelectors from './store/todo.selectors';
@Component({
selector: 'app-todo-list',
template: `
<div *ngIf="loading$ | async">Loading...</div>
<div *ngIf="error$ | async as error">Error: {{ error }}</div>
<ul>
<li *ngFor="let todo of todos$ | async">
{{ todo.title }}
<input
type="checkbox"
[checked]="todo.completed"
(change)="toggleTodo(todo.id)"
>
</li>
</ul>
`
})
export class TodoListComponent implements OnInit {
todos$ = this.store.select(TodoSelectors.selectAllTodos);
loading$ = this.store.select(TodoSelectors.selectLoading);
error$ = this.store.select(TodoSelectors.selectError);
constructor(private store: Store) {}
ngOnInit() {
this.store.dispatch(TodoActions.loadTodos());
}
toggleTodo(id: number) {
// Implement toggle action
}
}
Best Practices and Recommendations
-
Choose Based on Application Size:
- Small apps: Services with observables
- Medium apps: Signals
- Large apps: NgRx
-
State Structure:
- Keep state normalized
- Avoid duplicate data
- Consider using immutable patterns
-
Performance Considerations:
- Use selectors for derived state
- Implement proper memoization
- Avoid unnecessary state updates
Frequently Asked Questions
Q: When should I use Signals instead of Services with BehaviorSubject?
A: Use Signals when you need fine-grained reactivity and better performance. They're especially useful for UI state that changes frequently.
Q: Is NgRx overkill for small applications?
A: Yes, for small applications, NgRx can add unnecessary complexity. Start with services or signals and migrate to NgRx when needed.
Q: Can I mix different state management approaches?
A: Yes, you can use different approaches for different parts of your application. For example, use Signals for UI state and NgRx for complex domain state.
Q: How do Signals compare to RxJS Observables?
A: Signals are simpler and more performant for straightforward state management, while RxJS Observables offer more powerful data transformation and combination operators.
Conclusion
Angular offers multiple approaches to state management, each with its own strengths:
- Services with Observables: Simple and effective for small applications
- Signals: Modern, performant solution for reactive state management
- NgRx: Robust, scalable solution for large applications
Choose the approach that best fits your application's needs, considering factors like team size, application complexity, and scalability requirements.
Top comments (3)
The signals also slowly becoming a thing in this regard
I think you've missed NGXS there.
Try npmjs.com/package/ng-simple-state is a simple state management with only Services and RxJS or Signal.