Skip to main content

Angular NgRx Effects

Introduction

When working with NgRx for state management in Angular applications, you'll often need to perform operations that have side effects - operations that interact with external systems like making API calls, interacting with the browser's localStorage, or navigation. These operations don't fit neatly into the reducer pattern since reducers must be pure functions.

NgRx Effects solves this problem by providing a mechanism to handle side effects in a reactive way that integrates seamlessly with the rest of your NgRx architecture. In this guide, we'll explore how Effects work and how to implement them in your Angular applications.

What are NgRx Effects?

NgRx Effects are an integral part of the NgRx library that:

  • Listen for dispatched actions
  • Isolate side effects from components
  • Process actions and return new actions
  • Handle asynchronous operations like HTTP requests

Effects use RxJS to provide a declarative API to handle side effects, making your application more maintainable and testable.

Setting Up NgRx Effects

Before we start using Effects, we need to set up our environment:

Step 1: Install Required Packages

bash
npm install @ngrx/effects

Step 2: Import EffectsModule in AppModule

typescript
import { EffectsModule } from '@ngrx/effects';
import { UserEffects } from './effects/user.effects';

@NgModule({
imports: [
// Other imports
StoreModule.forRoot(reducers),
EffectsModule.forRoot([UserEffects])
]
})
export class AppModule {}

Creating Your First Effect

Let's create an effect that handles user authentication:

typescript
// user.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 { AuthService } from '../services/auth.service';
import * as UserActions from '../actions/user.actions';

@Injectable()
export class UserEffects {

login$ = createEffect(() => this.actions$.pipe(
// Filter for login action
ofType(UserActions.login),
// Perform side effect
mergeMap(action =>
this.authService.login(action.username, action.password).pipe(
// If successful, dispatch success action
map(user => UserActions.loginSuccess({ user })),
// If error, dispatch failure action
catchError(error => of(UserActions.loginFailure({ error: error.message })))
)
)
));

constructor(
private actions$: Actions,
private authService: AuthService
) {}
}

Let's break down this example:

  1. We import Actions and createEffect from @ngrx/effects
  2. We use ofType to filter for specific actions
  3. The mergeMap operator helps us to perform our side effect (API call)
  4. Based on the result of our API call, we map to a success or failure action
  5. The effect returns a new action that gets dispatched automatically

Defining Actions for Effects

For the above effect to work, we need to define the appropriate actions:

typescript
// user.actions.ts
import { createAction, props } from '@ngrx/store';
import { User } from '../models/user.model';

export const login = createAction(
'[Auth] Login',
props<{ username: string; password: string }>()
);

export const loginSuccess = createAction(
'[Auth] Login Success',
props<{ user: User }>()
);

export const loginFailure = createAction(
'[Auth] Login Failure',
props<{ error: string }>()
);

Non-Dispatching Effects

Sometimes, you might want an effect that doesn't dispatch a new action. For example, logging or navigating after an action:

typescript
logActions$ = createEffect(() => 
this.actions$.pipe(
tap(action => console.log(action)),
),
{ dispatch: false }
);

By setting { dispatch: false }, we tell NgRx that this effect doesn't dispatch any actions.

Handling Router Navigation with Effects

Effects are excellent for handling router navigation:

typescript
navigateToHome$ = createEffect(() => 
this.actions$.pipe(
ofType(UserActions.loginSuccess),
tap(() => this.router.navigate(['/home']))
),
{ dispatch: false }
);

Real-World Example: Todo Application with Effects

Let's see a more complete example of a Todo application using NgRx and Effects:

Step 1: Define Todo Models and Actions

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

// todo.actions.ts
import { createAction, props } from '@ngrx/store';
import { Todo } from '../models/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: string }>()
);

export const addTodo = createAction(
'[Todo] Add Todo',
props<{ title: string }>()
);
export const addTodoSuccess = createAction(
'[Todo] Add Todo Success',
props<{ todo: Todo }>()
);
export const addTodoFailure = createAction(
'[Todo] Add Todo Failure',
props<{ error: string }>()
);

Step 2: Create Todo Service

typescript
// todo.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Todo } from '../models/todo.model';

@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);
}

addTodo(title: string): Observable<Todo> {
return this.http.post<Todo>(this.apiUrl, {
title,
completed: false
});
}
}

Step 3: Create Todo Effects

typescript
// todo.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError, tap } from 'rxjs/operators';
import { TodoService } from '../services/todo.service';
import * as TodoActions from '../actions/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: error.message })))
)
)
));

addTodo$ = createEffect(() => this.actions$.pipe(
ofType(TodoActions.addTodo),
mergeMap(action =>
this.todoService.addTodo(action.title).pipe(
map(todo => TodoActions.addTodoSuccess({ todo })),
catchError(error => of(TodoActions.addTodoFailure({ error: error.message })))
)
)
));

// Show notification when todo is successfully added
todoAdded$ = createEffect(() =>
this.actions$.pipe(
ofType(TodoActions.addTodoSuccess),
tap(({ todo }) => {
this.notificationService.show(`Todo "${todo.title}" added successfully!`);
})
),
{ dispatch: false }
);

constructor(
private actions$: Actions,
private todoService: TodoService,
private notificationService: NotificationService
) {}
}

Step 4: Create Todo Reducer

typescript
// todo.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { Todo } from '../models/todo.model';
import * as TodoActions from '../actions/todo.actions';

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,
error: null
})),
on(TodoActions.loadTodosSuccess, (state, { todos }) => ({
...state,
todos,
loading: false
})),
on(TodoActions.loadTodosFailure, (state, { error }) => ({
...state,
error,
loading: false
})),
on(TodoActions.addTodo, state => ({
...state,
loading: true
})),
on(TodoActions.addTodoSuccess, (state, { todo }) => ({
...state,
todos: [...state.todos, todo],
loading: false
})),
on(TodoActions.addTodoFailure, (state, { error }) => ({
...state,
error,
loading: false
}))
);

Step 5: Use in Component

typescript
// 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 '../actions/todo.actions';
import { TodoState } from '../reducers/todo.reducer';

@Component({
selector: 'app-todo-list',
template: `
<div class="todo-container">
<h2>Todo List</h2>

<div class="add-todo">
<input #todoInput placeholder="Enter new todo..." />
<button (click)="addTodo(todoInput.value); todoInput.value = ''">Add</button>
</div>

<div *ngIf="loading$ | async" class="loading">Loading...</div>

<div *ngIf="error$ | async as error" class="error">{{ error }}</div>

<ul class="todo-list">
<li *ngFor="let todo of todos$ | async" [class.completed]="todo.completed">
{{ todo.title }}
</li>
</ul>
</div>
`
})
export class TodoListComponent implements OnInit {
todos$: Observable<Todo[]>;
loading$: Observable<boolean>;
error$: Observable<string | null>;

constructor(private store: Store<{ todos: TodoState }>) {
this.todos$ = this.store.select(state => state.todos.todos);
this.loading$ = this.store.select(state => state.todos.loading);
this.error$ = this.store.select(state => state.todos.error);
}

ngOnInit() {
this.store.dispatch(TodoActions.loadTodos());
}

addTodo(title: string) {
if (title.trim()) {
this.store.dispatch(TodoActions.addTodo({ title }));
}
}
}

Advanced Effect Patterns

Debounce for Search Input

When implementing search functionality, it's common to debounce user input:

typescript
searchBooks$ = createEffect(() => this.actions$.pipe(
ofType(BookActions.searchBooks),
debounceTime(300), // Wait 300ms between keystrokes
distinctUntilChanged(), // Only emit when query changes
switchMap(action =>
this.bookService.search(action.query).pipe(
map(books => BookActions.searchBooksSuccess({ books })),
catchError(error => of(BookActions.searchBooksFailure({ error: error.message })))
)
)
));

Cancelling Previous Requests

Sometimes you want to cancel previous in-flight requests when a new action is dispatched:

typescript
loadUserDetails$ = createEffect(() => this.actions$.pipe(
ofType(UserActions.loadUserDetails),
switchMap(action =>
this.userService.getUserDetails(action.userId).pipe(
map(user => UserActions.loadUserDetailsSuccess({ user })),
catchError(error => of(UserActions.loadUserDetailsFailure({ error: error.message })))
)
)
));

Using switchMap here means that if a new loadUserDetails action is dispatched while a previous request is still pending, the previous request will be cancelled.

Best Practices for NgRx Effects

  1. Keep effects focused: Each effect should handle a specific task
  2. Handle errors properly: Always use catchError to handle potential errors
  3. Consider concurrent requests: Choose the appropriate RxJS operators (mergeMap, switchMap, concatMap, or exhaustMap) based on your concurrency needs
  4. Test your effects: Write unit tests for effects using the provided testing utilities
  5. Use typed actions: Leverage TypeScript for type safety in your actions and effects
  6. Document complex effects: Add comments to explain complex logic

Summary

NgRx Effects provides a powerful way to handle side effects in your Angular applications while maintaining a clean separation of concerns. By isolating side effects from your components and reducers, you create code that is easier to test, maintain, and debug.

Key takeaways:

  • Effects listen for dispatched actions and perform side effects
  • They can dispatch new actions or not dispatch any actions at all
  • Effects help keep components clean and focused on presentation
  • They're perfect for API calls, navigation, notifications, and other async operations
  • Well-structured effects make your application more predictable and maintainable

Additional Resources

Exercises

  1. Create an effect that loads a user's profile when they log in successfully
  2. Implement an effect that saves a user's preferences to localStorage whenever they change
  3. Write an effect that tracks user actions and sends them to an analytics service
  4. Create an effect that shows a notification when certain actions occur in your application
  5. Implement an "undo" feature using effects that reverts the last action after a timeout


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)