Angular NgRx Store
Introduction
Managing state in complex Angular applications can become challenging as your application grows. Without a proper state management solution, you might encounter issues like unpredictable state mutations, difficulty tracking state changes, and components that are tightly coupled through shared state.
NgRx Store provides a robust solution to these problems by implementing the Redux pattern in Angular applications. It offers a centralized store for state management, ensuring predictable state changes through pure functions called reducers, and providing powerful tools to track and debug state changes over time.
In this guide, you'll learn:
- What NgRx Store is and why it's useful
- Core concepts of NgRx: Store, Actions, Reducers, and Selectors
- How to set up NgRx in an Angular application
- Practical examples of NgRx implementation
What is NgRx Store?
NgRx Store is a state management library for Angular applications inspired by Redux. It provides a way to manage global application state using a single, immutable data store. NgRx leverages RxJS to create observable-based state that your components can subscribe to.
Key Benefits of NgRx Store:
- Centralized State: All application state is stored in one location
- Immutability: State is never directly modified, reducing bugs
- Performance: Change detection is optimized through immutability
- Testability: Pure functions make testing easier
- Debugging: Time-travel debugging with Redux DevTools
Core NgRx Concepts
1. Store
The store is a single JavaScript object that holds the entire state of your application. It's the single source of truth for your application's data.
2. Actions
Actions describe unique events that happen in your application. They are the only way to change the state in the store. Actions have a type
property that describes what happened and may contain additional data in the payload
.
3. Reducers
Reducers are pure functions that specify how the state changes in response to actions. A reducer takes the current state and an action, and returns a new state object without modifying the original state.
4. Selectors
Selectors are pure functions used to select, derive and compose pieces of state from the store. They're efficient and can be composed together.
Setting Up NgRx in an Angular Application
Let's start by installing NgRx in an Angular project:
ng add @ngrx/store
ng add @ngrx/store-devtools
ng add @ngrx/effects
Creating a Basic Store
Let's create a simple counter application to understand NgRx better.
Step 1: Define the State Interface
// counter.state.ts
export interface CounterState {
count: number;
}
export const initialState: CounterState = {
count: 0
};
Step 2: Define Actions
// counter.actions.ts
import { createAction } from '@ngrx/store';
export const increment = createAction('[Counter] Increment');
export const decrement = createAction('[Counter] Decrement');
export const reset = createAction('[Counter] Reset');
Step 3: Create a Reducer
// counter.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { increment, decrement, reset } from './counter.actions';
import { initialState } from './counter.state';
export const counterReducer = createReducer(
initialState,
on(increment, state => ({ count: state.count + 1 })),
on(decrement, state => ({ count: state.count - 1 })),
on(reset, state => ({ count: 0 }))
);
Step 4: Register the Store in Your App Module
// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { counterReducer } from './counter.reducer';
import { environment } from '../environments/environment';
@NgModule({
imports: [
BrowserModule,
StoreModule.forRoot({ count: counterReducer }),
StoreDevtoolsModule.instrument({
maxAge: 25, // Retains last 25 states
logOnly: environment.production,
})
],
// other module properties...
})
export class AppModule { }
Step 5: Create Selectors
// counter.selectors.ts
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { CounterState } from './counter.state';
export const selectCounterState = createFeatureSelector<CounterState>('count');
export const selectCount = createSelector(
selectCounterState,
state => state.count
);
Step 6: Use the Store in a Component
// counter.component.ts
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { increment, decrement, reset } from './counter.actions';
import { selectCount } from './counter.selectors';
@Component({
selector: 'app-counter',
template: `
<div>
<h2>Count: {{ count$ | async }}</h2>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
count$: Observable<number>;
constructor(private store: Store) {
this.count$ = this.store.select(selectCount);
}
increment() {
this.store.dispatch(increment());
}
decrement() {
this.store.dispatch(decrement());
}
reset() {
this.store.dispatch(reset());
}
}
A More Complex Example: Managing a Todo List
Let's create a more comprehensive example with a todo list that demonstrates actions with payloads.
Define the State
// todo.state.ts
export interface Todo {
id: number;
text: string;
completed: boolean;
}
export interface TodoState {
todos: Todo[];
loading: boolean;
error: string | null;
}
export const initialState: TodoState = {
todos: [],
loading: false,
error: null
};
Define Actions with Payloads
// todo.actions.ts
import { createAction, props } from '@ngrx/store';
import { Todo } from './todo.state';
export const addTodo = createAction(
'[Todo] Add Todo',
props<{ text: string }>()
);
export const toggleTodo = createAction(
'[Todo] Toggle Todo',
props<{ id: number }>()
);
export const deleteTodo = createAction(
'[Todo] Delete Todo',
props<{ id: number }>()
);
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 }>()
);
Create the Reducer
// todo.reducer.ts
import { createReducer, on } from '@ngrx/store';
import {
addTodo,
toggleTodo,
deleteTodo,
loadTodos,
loadTodosSuccess,
loadTodosFailure
} from './todo.actions';
import { initialState } from './todo.state';
export const todoReducer = createReducer(
initialState,
on(addTodo, (state, { text }) => ({
...state,
todos: [...state.todos, {
id: Date.now(),
text,
completed: false
}]
})),
on(toggleTodo, (state, { id }) => ({
...state,
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
})),
on(deleteTodo, (state, { id }) => ({
...state,
todos: state.todos.filter(todo => todo.id !== id)
})),
on(loadTodos, state => ({
...state,
loading: true,
error: null
})),
on(loadTodosSuccess, (state, { todos }) => ({
...state,
todos,
loading: false
})),
on(loadTodosFailure, (state, { error }) => ({
...state,
error,
loading: false
}))
);
Create Selectors for the Todo State
// todo.selectors.ts
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { TodoState } from './todo.state';
export const selectTodoState = createFeatureSelector<TodoState>('todos');
export const selectAllTodos = createSelector(
selectTodoState,
state => state.todos
);
export const selectCompletedTodos = createSelector(
selectAllTodos,
todos => todos.filter(todo => todo.completed)
);
export const selectActiveTodos = createSelector(
selectAllTodos,
todos => todos.filter(todo => !todo.completed)
);
export const selectTodosLoading = createSelector(
selectTodoState,
state => state.loading
);
export const selectTodosError = createSelector(
selectTodoState,
state => state.error
);
Create Effects for Async Operations
Effects handle side effects like API calls. First, let's create a simple todo service:
// todo.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Todo } from './todo.state';
@Injectable({
providedIn: 'root'
})
export class TodoService {
private apiUrl = 'https://jsonplaceholder.typicode.com/todos';
constructor(private http: HttpClient) {}
getTodos(): Observable<Todo[]> {
return this.http.get<Todo[]>(this.apiUrl);
}
}
Now, let's create effects to handle async operations:
// 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 { TodoService } from './todo.service';
import {
loadTodos,
loadTodosSuccess,
loadTodosFailure
} from './todo.actions';
@Injectable()
export class TodoEffects {
loadTodos$ = createEffect(() => this.actions$.pipe(
ofType(loadTodos),
mergeMap(() => this.todoService.getTodos().pipe(
map(todos => loadTodosSuccess({ todos })),
catchError(error => of(loadTodosFailure({ error: error.message })))
))
));
constructor(
private actions$: Actions,
private todoService: TodoService
) {}
}
Register Everything in the App Module
// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { todoReducer } from './todo.reducer';
import { TodoEffects } from './todo.effects';
import { environment } from '../environments/environment';
@NgModule({
imports: [
BrowserModule,
HttpClientModule,
StoreModule.forRoot({ todos: todoReducer }),
EffectsModule.forRoot([TodoEffects]),
StoreDevtoolsModule.instrument({
maxAge: 25,
logOnly: environment.production,
})
],
// other module properties
})
export class AppModule { }
Use the Todo Store in a Component
// todo-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { Todo } from './todo.state';
import {
addTodo,
toggleTodo,
deleteTodo,
loadTodos
} from './todo.actions';
import {
selectAllTodos,
selectTodosLoading,
selectTodosError
} from './todo.selectors';
@Component({
selector: 'app-todo-list',
template: `
<div>
<h2>Todo List</h2>
<div *ngIf="loading$ | async">Loading todos...</div>
<div *ngIf="error$ | async as error" class="error">{{ error }}</div>
<form (ngSubmit)="onAddTodo()">
<input [(ngModel)]="newTodoText" name="newTodo" placeholder="Add a new task">
<button type="submit">Add</button>
</form>
<ul>
<li *ngFor="let todo of todos$ | async">
<input
type="checkbox"
[checked]="todo.completed"
(change)="onToggleTodo(todo.id)"
>
<span [class.completed]="todo.completed">{{ todo.text || todo.title }}</span>
<button (click)="onDeleteTodo(todo.id)">Delete</button>
</li>
</ul>
</div>
`,
styles: [`
.completed {
text-decoration: line-through;
color: gray;
}
.error {
color: red;
}
`]
})
export class TodoListComponent implements OnInit {
todos$: Observable<Todo[]>;
loading$: Observable<boolean>;
error$: Observable<string | null>;
newTodoText = '';
constructor(private store: Store) {
this.todos$ = this.store.select(selectAllTodos);
this.loading$ = this.store.select(selectTodosLoading);
this.error$ = this.store.select(selectTodosError);
}
ngOnInit() {
this.store.dispatch(loadTodos());
}
onAddTodo() {
if (this.newTodoText.trim() !== '') {
this.store.dispatch(addTodo({ text: this.newTodoText }));
this.newTodoText = '';
}
}
onToggleTodo(id: number) {
this.store.dispatch(toggleTodo({ id }));
}
onDeleteTodo(id: number) {
this.store.dispatch(deleteTodo({ id }));
}
}
Best Practices for NgRx
-
Follow the Single Responsibility Principle: Each part of NgRx (actions, reducers, selectors, effects) should have a clear responsibility.
-
Keep Reducers Pure: Reducers should be pure functions without side effects.
-
Normalize State Structure: For complex data, organize state in an entity-like structure.
-
Use Feature Modules: For large applications, break up your store into feature modules.
-
Use Selectors for Derived Data: Don't store calculated data; use selectors to derive it.
-
Use DevTools for Debugging: NgRx DevTools is invaluable for debugging state changes.
-
Treat Actions as Events, Not Commands: Actions should describe what happened, not what should happen.
Common NgRx Patterns
Feature State Module
For large applications, organize your store into feature modules:
// feature.module.ts
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { featureReducer } from './feature.reducer';
import { EffectsModule } from '@ngrx/effects';
import { FeatureEffects } from './feature.effects';
@NgModule({
imports: [
StoreModule.forFeature('featureName', featureReducer),
EffectsModule.forFeature([FeatureEffects])
]
})
export class FeatureModule { }
Entity Adapter
For collections of entities, use NgRx Entity to simplify CRUD operations:
// install first: npm install @ngrx/entity
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { Todo } from './todo.model';
export interface TodoState extends EntityState<Todo> {
loading: boolean;
error: string | null;
}
export const todoAdapter: EntityAdapter<Todo> = createEntityAdapter<Todo>({
selectId: todo => todo.id,
sortComparer: (a, b) => a.id - b.id
});
export const initialState: TodoState = todoAdapter.getInitialState({
loading: false,
error: null
});
Summary
NgRx Store is a powerful state management library for Angular applications, providing a predictable state container based on Redux principles. It helps maintain consistency throughout your application by centralizing state and enforcing a unidirectional data flow.
In this guide, you've learned:
- The core concepts of NgRx: Store, Actions, Reducers, Selectors, and Effects
- How to set up NgRx in an Angular application
- How to implement basic and advanced functionality with NgRx
- Best practices for using NgRx effectively
Using NgRx might seem like a lot of boilerplate initially, but the benefits of predictability, maintainability, and testability make it worthwhile for medium to large-scale applications.
Additional Resources
- Official NgRx Documentation
- NgRx GitHub Repository
- Redux DevTools Extension
- NgRx Example Application
Exercises
- Create a simple counter application using NgRx Store with increment, decrement, and reset functionality.
- Extend the todo list example to include filtering (all, active, completed) and a "clear completed" action.
- Implement a shopping cart feature with NgRx, including adding items, removing items, and calculating the total price.
- Create a bookshelf application where users can add, remove, and categorize books using NgRx Entity.
- Implement authentication state management with NgRx, including login, logout, and handling JWT tokens.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)