Table of Contents
- Understanding State in Angular
- What is State?
- Types of State
- When Do You Need State Management?
- Popular State Management Solutions for Angular
- NgRx: The Redux-Inspired Powerhouse
- Akita: Simplicity with Entity Support
- NgXs: Angular-First State Management
- Services + BehaviorSubject: For Small to Medium Apps
- Implementing State Management: Step-by-Step Examples
- Example 1: Simple State with BehaviorSubject
- Example 2: NgRx for a Todo Application
- Best Practices for State Management in Angular
- Conclusion
- References
1. Understanding State in Angular
What is State?
In Angular, state is the single source of truth for all data used by your application. It includes:
- User inputs (e.g., form values, selected checkboxes).
- Server data (e.g., user profiles, product listings).
- UI state (e.g., loading spinners, error messages, modal visibility).
- Session data (e.g., authentication tokens, user preferences).
Think of state as a JavaScript object that represents the current “snapshot” of your application. For example, a simple todo app’s state might look like this:
{
todos: [
{ id: 1, title: "Learn NgRx", completed: false },
{ id: 2, title: "Build a demo app", completed: true }
],
loading: false,
error: null,
user: { name: "John", isLoggedIn: true }
}
Types of State
Not all state is created equal. Angular applications typically handle three types of state:
- Server State: Data fetched from APIs (e.g., todos, user data). This is often asynchronous and may require caching, retries, or optimistic updates.
- Client State: Data local to the application (e.g., UI flags like
isMenuOpen, form inputs, or pagination settings). - Router State: Information about the current route (e.g., URL parameters, query strings). Angular’s
@angular/routermanages this by default, but it can be integrated with state management tools.
2. When Do You Need State Management?
State management tools add structure, but they also introduce boilerplate. Ask yourself these questions to decide if you need a dedicated solution:
- Is your app large or growing? Small apps (e.g., a single-form tool) may work with simple services. Large apps with many components sharing data (e.g., dashboards, e-commerce sites) benefit from centralized state.
- Do components share data? If multiple components (unrelated via parent-child) need access to the same data (e.g., a user profile displayed in headers, sidebars, and settings pages), centralizing state avoids prop-drilling (passing data through multiple component layers).
- Are there complex side effects? Asynchronous operations (API calls, timers) or race conditions (e.g., multiple API requests updating the same data) are easier to manage with tools like NgRx Effects.
- Do you need undo/redo or time-travel debugging? Tools like NgRx DevTools let you replay actions, making debugging easier.
- Is state consistency critical? Financial apps or real-time tools (e.g., chat apps) require strict control over state changes to avoid bugs.
If you answered “yes” to most, a state management library will save time in the long run. For small apps, Angular’s built-in features (services + BehaviorSubject) may suffice.
3. Popular State Management Solutions for Angular
Let’s compare the most common tools for managing state in Angular:
NgRx: The Redux-Inspired Powerhouse
What it is: NgRx is Angular’s most popular state management library, inspired by Redux. It follows the unidirectional data flow pattern and uses RxJS for reactivity.
Core Concepts:
- Store: A centralized container for the application state.
- Actions: Plain objects describing what happened (e.g.,
LoadTodos,AddTodo). - Reducers: Pure functions that take the current state and an action, then return a new state (immutability!).
- Effects: Handle side effects (API calls, timers) by listening to actions and dispatching new ones.
- Selectors: Memoized functions to extract and compute data from the store (optimizes performance by avoiding redundant calculations).
Pros: Mature ecosystem, strong community support, DevTools for debugging, integrates with Angular Router and Forms.
Cons: Steep learning curve, boilerplate-heavy.
Best For: Large enterprise apps with complex state and many side effects.
Akita: Simplicity with Entity Support
What it is: Akita, created by Datorama, prioritizes simplicity and reduces boilerplate compared to NgRx. It uses a repository pattern and built-in support for entity state (e.g., managing lists of objects with IDs).
Core Concepts:
- Store: Holds the state and provides methods to update it.
- EntityStore: Specialized store for collections (e.g., todos, users) with built-in CRUD methods.
- Query: Exposes observables to select data from the store.
- Service: Handles side effects (e.g., API calls).
Pros: Less boilerplate than NgRx, intuitive API, built-in entity management, good performance.
Cons: Smaller community than NgRx.
Best For: Medium to large apps where you want structure without excessive boilerplate.
NgXs: Angular-First State Management
What it is: NgXs is a lightweight alternative to NgRx, designed to feel “Angular-native” with decorators and dependency injection.
Core Concepts:
- Store: Centralized state container.
- Actions: Similar to NgRx, but defined with classes.
- State: Decorated classes that define the initial state and reducers (called “actions handlers”).
- Effects: Decorated methods in state classes to handle side effects.
Pros: Low boilerplate, Angular-like syntax (decorators), easy to learn.
Cons: Smaller ecosystem than NgRx.
Best For: Teams familiar with Angular decorators who want a balance of structure and simplicity.
Services + BehaviorSubject: For Small to Medium Apps
For simple apps, Angular’s built-in BehaviorSubject (from RxJS) and services are often sufficient. A BehaviorSubject emits the current value to new subscribers and allows updating the state via next().
Pros: No external dependencies, minimal boilerplate, easy to implement.
Cons: Lacks advanced features (e.g., DevTools, undo/redo), can become messy in large apps.
Best For: Small apps, prototypes, or features with isolated state (e.g., a single form or modal).
4. Implementing State Management: Step-by-Step Examples
Let’s walk through two practical implementations: a simple service-based solution with BehaviorSubject and a full NgRx setup for a todo app.
Example 1: Simple State with BehaviorSubject
Suppose we’re building a todo app with a few components that need to share the todo list. Here’s how to manage state with a service:
Step 1: Create a Todo Interface
Define the shape of a todo:
// src/app/models/todo.model.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
}
Step 2: Create a Todo Service
Use BehaviorSubject to hold the todo state and expose it as an observable. Add methods to update the state:
// src/app/services/todo.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { Todo } from '../models/todo.model';
@Injectable({ providedIn: 'root' })
export class TodoService {
// Private BehaviorSubject to hold the state
private readonly _todos = new BehaviorSubject<Todo[]>([]);
private readonly _loading = new BehaviorSubject<boolean>(false);
// Public observables for components to subscribe to
readonly todos$: Observable<Todo[]> = this._todos.asObservable();
readonly loading$: Observable<boolean> = this._loading.asObservable();
// Get current value (use sparingly; prefer observables)
private get todos(): Todo[] {
return this._todos.value;
}
// Load todos from an API (simulated)
loadTodos(): void {
this._loading.next(true);
// Simulate API call
setTimeout(() => {
const mockTodos: Todo[] = [
{ id: 1, title: 'Learn BehaviorSubject', completed: false },
{ id: 2, title: 'Build a demo', completed: true }
];
this._todos.next(mockTodos);
this._loading.next(false);
}, 1000);
}
// Add a new todo
addTodo(title: string): void {
const newTodo: Todo = {
id: Date.now(), // Simple unique ID
title,
completed: false
};
// Update state immutably (create a new array)
this._todos.next([...this.todos, newTodo]);
}
}
Step 3: Use the Service in a Component
Inject the service into a component and use the async pipe to subscribe to the state:
// src/app/todo-list/todo-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { Todo } from '../models/todo.model';
import { TodoService } from '../services/todo.service';
@Component({
selector: 'app-todo-list',
template: `
<div *ngIf="loading$ | async">Loading...</div>
<ul *ngIf="(todos$ | async) as todos">
<li *ngFor="let todo of todos">
{{ todo.title }} ({{ todo.completed ? 'Done' : 'Pending' }})
</li>
</ul>
<button (click)="addTodo()">Add Todo</button>
`
})
export class TodoListComponent implements OnInit {
todos$: Observable<Todo[]>;
loading$: Observable<boolean>;
constructor(private todoService: TodoService) {
this.todos$ = this.todoService.todos$;
this.loading$ = this.todoService.loading$;
}
ngOnInit(): void {
this.todoService.loadTodos();
}
addTodo(): void {
this.todoService.addTodo('New todo from component');
}
}
Example 2: NgRx for a Todo Application
Let’s scale up to NgRx for a more structured approach. We’ll build the same todo app with NgRx Store, Effects, and DevTools.
Step 1: Install NgRx Dependencies
npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools --save
Step 2: Define the State Interface
Create a TodoState interface to describe the shape of our todo state:
// src/app/state/todo/todo.state.ts
import { EntityState } from '@ngrx/entity';
import { Todo } from '../../models/todo.model';
// Use EntityState for easier collection management
export interface TodoState extends EntityState<Todo> {
loading: boolean;
error: string | null;
}
// Initial state
export const initialState: TodoState = {
ids: [],
entities: {},
loading: false,
error: null
};
Step 3: Define Actions
Actions describe events that modify the state. Create an actions.ts file:
// src/app/state/todo/todo.actions.ts
import { createAction, props } from '@ngrx/store';
import { Todo } from '../../models/todo.model';
// Load todos
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: string }>()
);
// Add todo
export const addTodo = createAction(
'[Todo] Add Todo',
props<{ todo: Todo }>()
);
Step 4: Create a Reducer
Reducers handle actions and update the state immutably. Use @ngrx/entity for CRUD operations on collections:
// src/app/state/todo/todo.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { Todo } from '../../models/todo.model';
import * as TodoActions from './todo.actions';
import { TodoState, initialState } from './todo.state';
// Create an entity adapter for todos
export const adapter: EntityAdapter<Todo> = createEntityAdapter<Todo>();
// Define the reducer
export const todoReducer = createReducer(
initialState,
// Handle loadTodos
on(TodoActions.loadTodos, (state) => ({
...state,
loading: true,
error: null
})),
// Handle loadTodosSuccess
on(TodoActions.loadTodosSuccess, (state, { todos }) => {
return adapter.setAll(todos, { ...state, loading: false });
}),
// Handle loadTodosFailure
on(TodoActions.loadTodosFailure, (state, { error }) => ({
...state,
loading: false,
error
})),
// Handle addTodo
on(TodoActions.addTodo, (state, { todo }) => {
return adapter.addOne(todo, state);
})
);
// Helper functions to select state slices
export const { selectAll, selectEntities, selectIds, selectTotal } = adapter.getSelectors();
Step 5: Create Effects for Side Effects
Use @ngrx/effects to handle API calls when loadTodos is dispatched:
// src/app/state/todo/todo.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import { Todo } from '../../models/todo.model';
import * as TodoActions from './todo.actions';
@Injectable()
export class TodoEffects {
// Effect to load todos from API
loadTodos$ = createEffect(() =>
this.actions$.pipe(
ofType(TodoActions.loadTodos),
mergeMap(() =>
this.http.get<Todo[]>('https://jsonplaceholder.typicode.com/todos').pipe(
map((todos) => TodoActions.loadTodosSuccess({ todos })),
catchError((error) => of(TodoActions.loadTodosFailure({ error: error.message })))
)
)
)
);
constructor(private actions$: Actions, private http: HttpClient) {}
}
Step 6: Define Selectors
Selectors extract data from the store. Create todo.selectors.ts:
// src/app/state/todo/todo.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { TodoState } from './todo.state';
import { selectAll } from './todo.reducer';
// Select the feature state (todo)
export const selectTodoState = createFeatureSelector<TodoState>('todo');
// Select all todos
export const selectAllTodos = createSelector(selectTodoState, selectAll);
// Select loading state
export const selectTodoLoading = createSelector(
selectTodoState,
(state: TodoState) => state.loading
);
Step 7: Configure the Store
Import StoreModule and EffectsModule in your root module (app.module.ts):
// src/app/app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { AppComponent } from './app.component';
import { todoReducer } from './state/todo/todo.reducer';
import { TodoEffects } from './state/todo/todo.effects';
import { TodoListComponent } from './todo-list/todo-list.component';
@NgModule({
declarations: [AppComponent, TodoListComponent],
imports: [
BrowserModule,
StoreModule.forRoot({ todo: todoReducer }), // Register the reducer
EffectsModule.forRoot([TodoEffects]), // Register effects
StoreDevtoolsModule.instrument() // Enable DevTools
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}
Step 8: Use the Store in a Component
Dispatch actions and select data in a component:
// src/app/todo-list/todo-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { Todo } from '../models/todo.model';
import * as TodoActions from '../state/todo/todo.actions';
import * as TodoSelectors from '../state/todo/todo.selectors';
@Component({
selector: 'app-todo-list',
template: `
<div *ngIf="loading$ | async">Loading...</div>
<div *ngIf="error$ | async as error">Error: {{ error }}</div>
<ul *ngIf="todos$ | async as todos">
<li *ngFor="let todo of todos">
{{ todo.title }}
</li>
</ul>
<button (click)="loadTodos()">Load Todos</button>
<button (click)="addTodo()">Add Todo</button>
`
})
export class TodoListComponent implements OnInit {
todos$: Observable<Todo[]>;
loading$: Observable<boolean>;
error$: Observable<string | null>;
constructor(private store: Store) {
this.todos$ = this.store.select(TodoSelectors.selectAllTodos);
this.loading$ = this.store.select(TodoSelectors.selectTodoLoading);
this.error$ = this.store.select((state) => state.todo.error);
}
ngOnInit(): void {
this.store.dispatch(TodoActions.loadTodos()); // Dispatch load action
}
addTodo(): void {
const newTodo: Todo = {
id: Date.now(),
title: 'New NgRx Todo',
completed: false
};
this.store.dispatch(TodoActions.addTodo({ todo: newTodo }));
}
}
Step 9: Test with NgRx DevTools
Open your browser’s DevTools (F12) and navigate to the “Redux” tab. You’ll see a log of actions, state changes, and can replay actions to debug!
5. Best Practices for State Management in Angular
Regardless of the tool you choose, follow these best practices:
- Keep State Immutable: Never mutate state directly. Reducers should return new state objects (NgRx enforces this; for
BehaviorSubject, use the spread operator orimmer). - Normalize State: Store collections as dictionaries (e.g.,
{ ids: [], entities: {} }) to avoid duplication and simplify updates (NgRx Entity and Akita handle this). - Centralize Only What’s Needed: Don’t put everything in the store. Local component state (e.g., a form input) can stay in the component.
- Use Selectors for Derived Data: Compute values (e.g.,
completedTodosCount) with selectors to avoid redundant calculations. - Handle Side Effects Explicitly: Use effects (NgRx, NgXs) or services (Akita) to manage API calls, timers, or other async logic—never in reducers!
- Test Thoroughly: Write unit tests for reducers, effects, and selectors. NgRx provides utilities like
@ngrx/effects/testingfor this.
6. Conclusion
State management is a critical part of building scalable Angular applications. The right tool depends on your app’s complexity:
- Small apps: Use
BehaviorSubject+ services for simplicity. - Medium apps: Try Akita or NgXs for structure with less boilerplate.
- Large apps: NgRx offers a robust, battle-tested solution with DevTools and ecosystem support.
By centralizing state, you’ll reduce bugs, simplify debugging, and make your codebase easier to maintain. Start with the simplest solution that meets your needs, and scale up as your app grows!