Skip to content

Angular State Management with NgRx Explained — Complete Guide

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about Angular State Management with NgRx Explained. We cover key concepts, practical examples, and best practices to help you master this topic.

NgRx is a state management library for Angular based on the Redux pattern, providing a predictable state container with unidirectional data flow for complex applications.

What You'll Learn

  • The Redux pattern and why it matters for Angular
  • How to set up NgRx Store, Actions, and Reducers
  • How to use Selectors to query state efficiently
  • How to handle side effects with NgRx Effects
  • How to use NgRx Entity for normalized data

Why It Matters

As Angular apps grow, managing shared state becomes complex. NgRx provides a single source of truth, predictable state changes, and powerful developer tools for debugging. It is the standard for enterprise Angular applications.

Real-World Use

Durga Antivirus Pro uses NgRx to manage scan state, threat data, user preferences, and notification state across dozens of components. The NgRx DevTools allow developers to replay state changes during debugging.

flowchart LR
    A[Component] -->|dispatch Action| B[Store]
    B -->|Reducer| C[New State]
    C -->|Selector| A
    B -->|Action Stream| D[Effect]
    D -->|API Call| E[External Service]
    E -->|New Action| B
    style A fill:#f97316,color:#fff

Core Concepts

NgRx follows the Redux pattern with three core principles:

  1. Single source of truth — The application state is stored in a single object tree within the Store.
  2. State is read-only — State can only be changed by dispatching Actions.
  3. Changes are made with pure functions — Reducers are pure functions that take the current state and an action, and return a new state.

Setting Up NgRx

Install NgRx packages:

npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools

Configure the store in the app:

import { bootstrapApplication } from "@angular/platform-browser";
import { provideState, provideStore } from "@ngrx/store";
import { provideEffects } from "@ngrx/effects";
import { provideStoreDevtools } from "@ngrx/store-devtools";
import { AppComponent } from "./app/app.component";
import { taskReducer } from "./store/task.reducer";
import { TaskEffects } from "./store/task.effects";

bootstrapApplication(AppComponent, {
  providers: [
    provideStore(),
    provideState({ name: "tasks", reducer: taskReducer }),
    provideEffects([TaskEffects]),
    provideStoreDevtools({ maxAge: 25 })
  ]
});

Expected output: NgRx is initialized with a task feature state. The DevTools extension enables time-travel debugging.

provideStore() creates the root store. provideState registers feature states. Each feature state is a slice of the global state tree.

Actions

Actions describe state changes as events:

import { createAction, props } from "@ngrx/store";

export interface Task {
  id: string;
  title: string;
  completed: boolean;
}

// Action types
export const loadTasks = createAction("[Task List] Load Tasks");
export const loadTasksSuccess = createAction(
  "[Task API] Load Tasks Success",
  props<{ tasks: Task[] }>()
);
export const loadTasksFailure = createAction(
  "[Task API] Load Tasks Failure",
  props<{ error: string }>()
);

export const addTask = createAction(
  "[Task List] Add Task",
  props<{ title: string }>()
);

export const addTaskSuccess = createAction(
  "[Task API] Add Task Success",
  props<{ task: Task }>()
);

export const toggleTask = createAction(
  "[Task List] Toggle Task",
  props<{ id: string }>()
);

export const removeTask = createAction(
  "[Task List] Remove Task",
  props<{ id: string }>()
);

Expected output: Strongly-typed action creators with descriptive type strings.

Action types follow the convention [Source] Event. This makes it clear where the action originates and what it represents. Each action is a function that returns an action object with type and props properties.

Reducers

Reducers are pure functions that transform state:

import { createReducer, on } from "@ngrx/store";
import * as TaskActions from "./task.actions";
import { Task } from "./task.actions";

export interface TaskState {
  tasks: Task[];
  loading: boolean;
  error: string | null;
}

export const initialState: TaskState = {
  tasks: [],
  loading: false,
  error: null
};

export const taskReducer = createReducer(
  initialState,

  on(TaskActions.loadTasks, state => ({
    ...state,
    loading: true,
    error: null
  })),

  on(TaskActions.loadTasksSuccess, (state, { tasks }) => ({
    ...state,
    tasks,
    loading: false
  })),

  on(TaskActions.loadTasksFailure, (state, { error }) => ({
    ...state,
    error,
    loading: false
  })),

  on(TaskActions.addTaskSuccess, (state, { task }) => ({
    ...state,
    tasks: [...state.tasks, task]
  })),

  on(TaskActions.toggleTask, (state, { id }) => ({
    ...state,
    tasks: state.tasks.map(task =>
      task.id === id ? { ...task, completed: !task.completed } : task
    )
  })),

  on(TaskActions.removeTask, (state, { id }) => ({
    ...state,
    tasks: state.tasks.filter(task => task.id !== id)
  }))
);

Expected output: State transitions are explicit and predictable. Each action handler returns a new state object with immutable updates.

Reducers must be pure: no side effects, no API calls, no random values. The spread operator (...state) ensures immutability. Each handler returns a brand new state reference.

Selectors

Selectors efficiently derive data from the store:

import { createSelector, createFeatureSelector } from "@ngrx/store";
import { TaskState } from "./task.reducer";
import { Task } from "./task.actions";

// Feature selector
export const selectTaskState = createFeatureSelector<TaskState>("tasks");

// Base selectors
export const selectAllTasks = createSelector(
  selectTaskState,
  state => state.tasks
);

export const selectLoading = createSelector(
  selectTaskState,
  state => state.loading
);

export const selectError = createSelector(
  selectTaskState,
  state => state.error
);

// Derived selectors
export const selectCompletedTasks = createSelector(
  selectAllTasks,
  tasks => tasks.filter(task => task.completed)
);

export const selectPendingTasks = createSelector(
  selectAllTasks,
  tasks => tasks.filter(task => !task.completed)
);

export const selectTaskCount = createSelector(
  selectAllTasks,
  tasks => tasks.length
);

export const selectTaskById = (id: string) => createSelector(
  selectAllTasks,
  tasks => tasks.find(task => task.id === id)
);

Expected output: Selectors compute derived state efficiently with memoization. The same selector with the same arguments returns the cached result.

Selectors compose. createFeatureSelector selects the feature slice. createSelector combines selectors to compute derived values. Selectors are re-evaluated only when their input state changes.

Effects

Effects handle side effects like API calls:

import { Injectable, inject } from "@angular/core";
import { Actions, createEffect, ofType } from "@ngrx/effects";
import { HttpClient } from "@angular/common/http";
import { of } from "rxjs";
import { catchError, map, mergeMap, switchMap } from "rxjs/operators";
import * as TaskActions from "./task.actions";
import { Task } from "./task.actions";

@Injectable()
export class TaskEffects {
  private actions$ = inject(Actions);
  private http = inject(HttpClient);

  loadTasks$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TaskActions.loadTasks),
      switchMap(() =>
        this.http.get<Task[]>("https://jsonplaceholder.typicode.com/todos?_limit=10").pipe(
          map(tasks => TaskActions.loadTasksSuccess({ tasks })),
          catchError(error => of(TaskActions.loadTasksFailure({ error: error.message })))
        )
      )
    )
  );

  addTask$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TaskActions.addTask),
      switchMap(action =>
        this.http.post<Task>("https://jsonplaceholder.typicode.com/todos", {
          title: action.title,
          completed: false
        }).pipe(
          map(task => TaskActions.addTaskSuccess({ task })),
          catchError(error => of(TaskActions.loadTasksFailure({ error: error.message })))
        )
      )
    )
  );
}

Expected output: When loadTasks dispatches, the effect makes an HTTP call and dispatches success or failure.

Effects listen for specific actions using ofType, perform side effects, and dispatch new actions with the results. They return an Observable of actions that the store dispatches.

Using Store in Components

Components interact with the store through selectors and dispatch:

import { Component, OnInit } from "@angular/core";
import { Store } from "@ngrx/store";
import { Observable } from "rxjs";
import { CommonModule } from "@angular/common";
import * as TaskActions from "./store/task.actions";
import { selectAllTasks, selectLoading, selectTaskCount } from "./store/task.selectors";
import { Task } from "./store/task.actions";

@Component({
  selector: "app-task-list",
  standalone: true,
  imports: [CommonModule],
  template: `
    <div *ngIf="loading$ | async">Loading tasks...</div>
    <p>Total tasks: {{ taskCount$ | async }}</p>
    <ul>
      <li *ngFor="let task of tasks$ | async">
        <input type="checkbox" [checked]="task.completed"
          (change)="toggleTask(task.id)" />
        {{ task.title }}
      </li>
    </ul>
    <button (click)="addSampleTask()">Add Sample Task</button>
  `
})
export class TaskListComponent implements OnInit {
  tasks$: Observable<Task[]>;
  loading$: Observable<boolean>;
  taskCount$: Observable<number>;

  constructor(private store: Store) {
    this.tasks$ = this.store.select(selectAllTasks);
    this.loading$ = this.store.select(selectLoading);
    this.taskCount$ = this.store.select(selectTaskCount);
  }

  ngOnInit() {
    this.store.dispatch(TaskActions.loadTasks());
  }

  toggleTask(id: string) {
    this.store.dispatch(TaskActions.toggleTask({ id }));
  }

  addSampleTask() {
    this.store.dispatch(TaskActions.addTask({ title: "New Task " + Date.now() }));
  }
}

Expected output: The component displays tasks from the store, dispatches actions on user interaction, and shows a loading indicator while fetching.

Components use the async pipe to subscribe to selectors. They never modify state directly — they dispatch actions that go through reducers to produce new state.

Common Mistakes

  1. Putting non-serializable data in the store — The store should only contain serializable data. Functions, class instances, and DOM elements do not belong in the store.

  2. Side effects in reducers — Reducers must be pure. API calls, localStorage access, or random value generation belong in Effects, not reducers.

  3. Over-normalizing state — Not everything belongs in NgRx. Local form state, UI toggles, and temporary data can stay in components.

  4. Selectors that return new references each time — Selectors should memoize. If a selector returns a new array each call, it triggers unnecessary change detection.

  5. Not using Entity for collections — NgRx Entity provides optimized reducer functions for CRUD operations on collections. Use it instead of manual array updates.

Practice Questions

  1. What are the three core principles of NgRx? Single source of truth, state is read-only, changes are made with pure functions (reducers).

  2. What is the purpose of an Effect? To handle side effects like API calls, timers, and external interactions by dispatching actions with results.

  3. What is a Selector? A pure function that derives data from the store state with memoization for performance.

  4. How do components read state from the store? Using the store.select(selector) method or the async pipe with selectors.

  5. What is NgRx Entity? A library that provides normalized state management for collections with optimized reducer functions.

Challenge

Build a NgRx store for a ShoppingCartModule. Create actions for addItem, removeItem, updateQuantity, applyCoupon, clearCart. Create reducers with NgRx Entity for cart items. Create selectors for itemCount, subtotal, tax, discount, and total. Create an effect that saves the cart to localStorage on every change and loads it on init.

FAQ

Do I need NgRx for every Angular app?

No. NgRx adds complexity. Use it when multiple components share state, the state shape is complex, or you need time-travel debugging.

What is the difference between NgRx and Signals?

Signals are a reactivity primitive for Angular. NgRx is a state management pattern. They can be used together.

How do I debug NgRx?

Use the Redux DevTools browser extension. It shows every action, state change, and allows time-travel debugging.

Can NgRx be lazy loaded?

Yes, use provideState with the same feature key in the lazy-loaded route configuration. The state merges with the root store.

What is metaReducer?

A higher-order reducer that wraps all reducers. Useful for logging, state persistence, or resetting state on logout.

Mini Project

Build a TaskBoardApp with full NgRx state management. Create actions for CRUD operations on tasks and task lists. Use NgRx Entity for tasks within each list. Create effects that simulate API calls (use of with delay). Create selectors for task counts per list, overdue tasks, and completed tasks. Build components: BoardComponent, ListComponent, TaskCardComponent. Use the DevTools to verify action flow. Add a feature for dragging tasks between lists with appropriate actions.

What's Next

Complete the Angular learning path with the final project:

Angular Project, Angular Signals, Angular Standalone

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro