Mastering State with NgRx: A Step-by-Step Tutorial

Managing state in large-scale Angular applications can quickly become complex. As your application grows, tracking data changes, handling side effects, and ensuring a predictable data flow can become a significant challenge. This is where NgRx comes in.

Inspired by the Redux pattern, NgRx provides a robust and scalable solution for centralized state management in Angular. It enforces a unidirectional data flow, making your application’s state predictable, testable, and easier to debug.

In this tutorial, we’ll dive deep into NgRx, covering its core concepts and walking through a practical example. By the end, you’ll have a solid understanding of how to leverage NgRx to build more maintainable and scalable Angular apps.

1. Getting Started: Setting up NgRx

First, let’s set up a new Angular project and install the necessary NgRx packages.

# Create a new Angular project (if you don't have one)
ng new ngrx-tutorial --defaults --routing=false

# Navigate into your project directory
cd ngrx-tutorial

# Add NgRx to your project
# This command installs @ngrx/store, @ngrx/effects, @ngrx/store-devtools, and @ngrx/entity
ng add @ngrx/store

When you run ng add @ngrx/store, the Angular CLI will prompt you to set up a root state. You can accept the defaults, and it will generate an app.module.ts (or app.config.ts if using standalone components) with the StoreModule.forRoot configuration.

2. Core Concepts of NgRx

NgRx revolves around several key concepts: Store, Actions, Reducers, Selectors, and Effects. Let’s break them down.

A. Actions: Describing Events

Actions are plain objects that describe unique events that happen in your application. They are the only way to initiate a state change. Think of them as messages saying, “Something just happened!”

Each action must have a type property (a unique string) and can optionally carry a payload (data related to the event).

Let’s define some actions for a simple counter application (increment, decrement, reset).

Create a new file src/app/counter.actions.ts:

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

// Define an action for incrementing the counter
// The 'createAction' function returns a factory function that creates action objects
// The first argument is a string description, which also serves as the action's type
export const increment = createAction('[Counter] Increment');

// Define an action for decrementing the counter
export const decrement = createAction('[Counter] Decrement');

// Define an action for resetting the counter
// 'props' is used to define optional payload properties for the action
// Here, the 'reset' action will carry a 'count' number as its payload
export const reset = createAction('[Counter] Reset', props<{ count: number }>());

// We will export these action creators to be used when dispatching actions from components or effects.

B. Reducers: Handling State Changes

Reducers are pure JavaScript functions that take the current state and an action, and return a new state. They are responsible for handling state transitions in an immutable way. “Pure” means they don’t modify the existing state directly; they always return a new state object.

Let’s create a reducer for our counter.

Create a new file src/app/counter.reducer.ts:

import { createReducer, on } from '@ngrx/store';
import { increment, decrement, reset } from './counter.actions';

// Define the initial state for our counter
// This is the starting point of our application's state slice
export interface CounterState {
  count: number;
}

export const initialState: CounterState = {
  count: 0,
};

// Create the reducer function using 'createReducer'
// The first argument is the initial state
// The subsequent arguments are 'on' functions, which map actions to state transformations
export const counterReducer = createReducer(
  initialState,
  // When the 'increment' action is dispatched, create a new state object
  // by increasing the 'count' property by 1.
  on(increment, (state) => ({ ...state, count: state.count + 1 })),
  // When the 'decrement' action is dispatched, create a new state object
  // by decreasing the 'count' property by 1.
  on(decrement, (state) => ({ ...state, count: state.count - 1 })),
  // When the 'reset' action is dispatched, create a new state object
  // by setting the 'count' property to the 'count' value from the action's payload.
  on(reset, (state, { count }) => ({ ...state, count })),
);

// This reducer now knows how to react to our defined actions and immutably update the counter state.

C. Store: The Single Source of Truth

The Store is the central piece of NgRx. It holds the entire application state in a single, immutable object. Components do not directly modify the store; instead, they dispatch actions to express their intent to change the state.

To make our reducer available to the store, we need to register it in our Angular application’s root module (or app.config.ts for standalone applications).

Open src/app/app.module.ts (or src/app/app.config.ts) and modify it:

For app.module.ts:

import { NgModule, isDevMode } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools'; // Import Devtools
import { AppComponent } from './app.component';
import { counterReducer } from './counter.reducer'; // Import our reducer
import { CommonModule } from '@angular/common'; // Import CommonModule for ngIf, ngFor etc.

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    CommonModule, // Add CommonModule here
    // Register our reducer with the store
    // The 'appState' key defines the name of this slice of state in the global store
    StoreModule.forRoot({ appState: counterReducer }),
    // Configure Store Devtools
    // It's recommended to enable them only in development mode
    StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: !isDevMode() })
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

For app.config.ts (if using standalone components):

import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';

import { routes } from './app.routes';
import { counterReducer } from './counter.reducer'; // Import our reducer

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    // Register our reducer with the store
    // The 'appState' key defines the name of this slice of state in the global store
    provideStore({ appState: counterReducer }),
    // Configure Store Devtools
    // It's recommended to enable them only in development mode
    provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() })
  ]
};

D. Selectors: Querying the State

Selectors are pure functions used to query, compute, or transform slices of the state from the store. They provide a performant way to access specific pieces of data without re-computing expensive operations if the relevant part of the state hasn’t changed.

Create a new file src/app/counter.selectors.ts:

import { createFeatureSelector, createSelector } from '@ngrx/store';
import { CounterState } from './counter.reducer'; // Import the state interface

// 1. Create a feature selector to get the entire 'appState' slice from the global store.
// This matches the key we used in StoreModule.forRoot({ appState: counterReducer })
export const selectAppState = createFeatureSelector<CounterState>('appState');

// 2. Create a selector to get the 'count' property from the 'appState' slice.
// This selector takes 'selectAppState' as an input and returns the 'count' property.
export const selectCount = createSelector(
  selectAppState,
  (state: CounterState) => state.count
);

// You can create more complex selectors if needed, for example:
// export const selectIsEven = createSelector(
//   selectCount,
//   (count: number) => count % 2 === 0
// );

E. Effects: Handling Side Effects

Effects are classes that listen for dispatched actions and perform side effects (e.g., fetching data from an API, interacting with localStorage, routing). Once the side effect is complete, an Effect typically dispatches a new action to update the state in the store.

For our simple counter, we might not strictly need an Effect, but let’s illustrate how one would be set up if, for example, resetting the counter involved an API call.

First, let’s pretend we have a service to simulate an API call. Create src/app/counter-api.service.ts:

import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class CounterApiService {
  constructor() { }

  // Simulate an API call to get an initial count
  getInitialCount(): Observable<number> {
    console.log('API call: Getting initial count...');
    return of(100).pipe(delay(1000)); // Simulate network delay
  }
}

Now, create an effect. Create src/app/counter.effects.ts:

import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, exhaustMap, catchError } from 'rxjs/operators';
import { reset, increment } from './counter.actions'; // Import existing actions
import { CounterApiService } from './counter-api.service'; // Import our fake API service

// Define a new action that will be dispatched when the initial count is loaded successfully
export const loadInitialCountSuccess = createAction(
  '[Counter API] Load Initial Count Success',
  props<{ count: number }>()
);

// Define a new action that will be dispatched if loading the initial count fails
export const loadInitialCountFailure = createAction(
  '[Counter API] Load Initial Count Failure',
  props<{ error: any }>()
);

// Define an action to trigger loading the initial count
export const loadInitialCount = createAction('[Counter] Load Initial Count');

@Injectable()
export class CounterEffects {
  constructor(
    private actions$: Actions, // Actions stream provided by NgRx Effects
    private counterApiService: CounterApiService // Our simulated API service
  ) {}

  // This effect listens for the 'loadInitialCount' action.
  // When it's dispatched, it calls the API service.
  // If successful, it dispatches 'loadInitialCountSuccess'.
  // If there's an error, it dispatches 'loadInitialCountFailure'.
  loadInitialCount$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loadInitialCount), // Filter the action stream to only react to 'loadInitialCount'
      exhaustMap(() =>
        this.counterApiService.getInitialCount().pipe(
          map(count => loadInitialCountSuccess({ count })), // On success, map to success action
          catchError(error => of(loadInitialCountFailure({ error }))) // On error, map to failure action
        )
      )
    )
  );

  // This is an example of a "non-dispatching" effect (optional: { dispatch: false }).
  // It could be used for logging or analytics, without dispatching a new action.
  // For demonstration, let's just log when increment happens.
  logIncrement$ = createEffect(() =>
    this.actions$.pipe(
      ofType(increment),
      map(() => {
        console.log('Action: Increment was dispatched!');
        // Note: Because dispatch is false, no new action is dispatched by this effect.
      })
    ),
    { dispatch: false } // Important: tells NgRx this effect does not dispatch an action
  );
}

Finally, register the effects in your root module (or app.config.ts).

For app.module.ts:

import { NgModule, isDevMode } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { EffectsModule } from '@ngrx/effects'; // Import EffectsModule
import { AppComponent } from './app.component';
import { counterReducer } from './counter.reducer';
import { CounterEffects } from './counter.effects'; // Import our effects
import { CommonModule } from '@angular/common';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    CommonModule,
    StoreModule.forRoot({ appState: counterReducer }),
    // Register our effects
    EffectsModule.forRoot([CounterEffects]), // Add our effects here
    StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: !isDevMode() })
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

For app.config.ts (if using standalone components):

import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { provideEffects } from '@ngrx/effects'; // Import provideEffects

import { routes } from './app.routes';
import { counterReducer } from './counter.reducer';
import { CounterEffects } from './counter.effects'; // Import our effects

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideStore({ appState: counterReducer }),
    // Register our effects
    provideEffects([CounterEffects]), // Add our effects here
    provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() })
  ]
};

3. Putting It All Together: The Counter Application

Now that we have all the pieces, let’s connect them in our AppComponent. We’ll dispatch actions, select state, and see the unidirectional flow in action.

Open src/app/app.component.ts:

import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { increment, decrement, reset } from './counter.actions'; // Import our actions
import { loadInitialCount, loadInitialCountSuccess, loadInitialCountFailure } from './counter.effects'; // Import new actions from effects
import { selectCount } from './counter.selectors'; // Import our selector
import { CounterState } from './counter.reducer'; // Import state interface

@Component({
  selector: 'app-root',
  // Ensure the template is updated to use the 'async' pipe and new buttons
  template: `
    <div class="container mx-auto p-6 md:p-10 bg-white rounded-lg shadow-xl mt-8">
      <h1 class="text-4xl font-extrabold text-center text-cyan-700 mb-8">NgRx Counter App</h1>

      <div class="flex flex-col items-center justify-center space-y-6">
        <p class="text-6xl font-bold text-emerald-600">
          Current Count: {{ count$ | async }}
        </p>

        <div class="flex space-x-4">
          <button
            (click)="increment()"
            class="px-8 py-3 bg-cyan-500 text-white rounded-full text-lg font-semibold hover:bg-cyan-600 focus:outline-none focus:ring-4 focus:ring-cyan-300 transition duration-300 ease-in-out transform hover:scale-105 shadow-lg"
          >
            Increment
          </button>
          <button
            (click)="decrement()"
            class="px-8 py-3 bg-amber-500 text-white rounded-full text-lg font-semibold hover:bg-amber-600 focus:outline-none focus:ring-4 focus:ring-amber-300 transition duration-300 ease-in-out transform hover:scale-105 shadow-lg"
          >
            Decrement
          </button>
        </div>

        <div class="flex space-x-4 mt-4">
          <button
            (click)="reset(0)"
            class="px-8 py-3 bg-violet-500 text-white rounded-full text-lg font-semibold hover:bg-violet-600 focus:outline-none focus:ring-4 focus:ring-violet-300 transition duration-300 ease-in-out transform hover:scale-105 shadow-lg"
          >
            Reset to 0
          </button>
          <button
            (click)="loadCount()"
            class="px-8 py-3 bg-rose-500 text-white rounded-full text-lg font-semibold hover:bg-rose-600 focus:outline-none focus:ring-4 focus:ring-rose-300 transition duration-300 ease-in-out transform hover:scale-105 shadow-lg"
          >
            Load Initial Count (from Effect)
          </button>
        </div>
        <p *ngIf="errorMessage" class="text-red-600 mt-4 text-lg font-semibold">
          Error: {{ errorMessage }}
        </p>
      </div>
    </div>
  `,
  // Add standalone: true if you are using app.config.ts
  // standalone: true,
  // imports: [CommonModule], // Add CommonModule here for standalone components
})
export class AppComponent implements OnInit {
  count$: Observable<number>; // Observable to hold our counter value
  errorMessage: string | null = null;

  constructor(private store: Store<{ appState: CounterState }>) {
    // Select the 'count' from the store using our selector
    this.count$ = this.store.select(selectCount);
  }

  ngOnInit(): void {
    // We can also subscribe to specific actions for debugging or side effects not handled by NgRx Effects
    this.store.select(state => state).subscribe(fullState => {
      console.log('Full State Change:', fullState);
    });

    // Example of subscribing to an action result to display an error
    this.store.select(state => state).subscribe(fullState => {
        const error = (fullState as any).appState?.error; // Assuming an 'error' property might exist on appState
        if (error) {
            this.errorMessage = error.message || 'An unknown error occurred.';
        } else {
            this.errorMessage = null;
        }
    });
  }

  // Dispatch the 'increment' action
  increment(): void {
    this.store.dispatch(increment());
  }

  // Dispatch the 'decrement' action
  decrement(): void {
    this.store.dispatch(decrement());
  }

  // Dispatch the 'reset' action with a payload
  reset(value: number): void {
    this.store.dispatch(reset({ count: value }));
  }

  // Dispatch the action to trigger our Effect to load initial count
  loadCount(): void {
    this.errorMessage = null; // Clear previous errors
    this.store.dispatch(loadInitialCount());
    // Also, subscribe to success/failure actions if you need to react in the component
    this.store.select(state => state).subscribe(fullState => {
      const appState = (fullState as any).appState;
      if (appState && typeof appState.count === 'number' && appState.count !== 0) {
        console.log('Initial count loaded:', appState.count);
      }
    });
  }
}

Open src/styles.css and add the Inter font if not already there, and set some basic Tailwind styling:

@tailwind base;
@tailwind components;
@tailwind utilities;

html, body {
  height: 100%;
  margin: 0;
  font-family: 'Inter', sans-serif;
  background-color: #f1f5f9; /* Tailwind slate-100 */
  color: #334155; /* Tailwind slate-800 */
}

Now, run your application:

ng serve

Open your browser to http://localhost:4200. You should see the counter application. Open your browser’s developer tools and look for the Redux DevTools extension (if you installed it). You’ll be able to see every action dispatched, the state changes, and even time-travel through them!

Conclusion

Congratulations! 🎉 You’ve now implemented a basic NgRx-powered counter application. You’ve learned about:

  • Actions for describing events.
  • Reducers for immutable state transformations.
  • The Store as your single source of truth.
  • Selectors for querying state.
  • Effects for managing side effects.

NgRx can seem daunting at first due to its explicit patterns and boilerplate, but this very structure is what makes it so powerful for complex applications. It encourages clean architecture, promotes testability, and provides unparalleled debugging capabilities.