Introduction to NgRx in Angular

Overview of NgRx

NgRx is a powerful state management library for Angular applications inspired by the Redux pattern. It enables developers to manage complex application states in a predictable, reactive, and centralized way.

At its core, NgRx uses Actions, Reducers, Selectors, and Effects to handle application logic and data flow. It leverages RxJS Observables to create reactive and unidirectional data streams that keep your application consistent and scalable.

NgRx helps solve a common Angular challenge — managing shared state across multiple components while keeping data synchronized and predictable.

Why Use NgRx?

As applications grow, managing state becomes challenging. Components need to share data, respond to user interactions, and update the UI efficiently.

NgRx provides a single source of truth for your application state, making it easier to:

  1. Predict and debug state changes.
  2. Share data between components easily.
  3. Handle complex asynchronous operations.
  4. Improve maintainability and scalability.

Core Concepts of NgRx

Before diving into code, let’s understand the core building blocks of NgRx:

ConceptDescription
StoreCentralized state container that holds the application state.
ActionAn event that describes a change in the application.
ReducerA pure function that determines how the state changes based on an action.
SelectorFunctions used to retrieve specific pieces of state from the store.
EffectHandles side effects such as API calls and dispatches new actions based on responses.

Installing NgRx

You can install NgRx packages using Angular CLI or npm.

Command:

ng add @ngrx/store
ng add @ngrx/effects
ng add @ngrx/store-devtools

Alternatively:

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

Setting Up the Store

First, configure the NgRx store in your application module.

app.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { StoreModule } from '@ngrx/store';
import { AppComponent } from './app.component';
import { counterReducer } from './state/counter.reducer';

@NgModule({
  declarations: [AppComponent],
  imports: [
BrowserModule,
StoreModule.forRoot({ counter: counterReducer })
], bootstrap: [AppComponent] }) export class AppModule {}

This setup registers a global store with one feature slice — counter.


Defining Actions

Actions describe what happened in the application. They are plain objects containing a type and optionally a payload.

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');

Actions are dispatched to the store to trigger state changes.


Creating a Reducer

A reducer is a pure function that takes the current state and an action, then returns a new state.

counter.reducer.ts

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

export const initialState = 0;

export const counterReducer = createReducer(
  initialState,
  on(increment, state => state + 1),
  on(decrement, state => state - 1),
  on(reset, state => 0)
);

Reducers must be pure — they cannot modify the state directly, only return a new one.


Dispatching Actions from a Component

Actions are dispatched using the Store service.

counter.component.ts

import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { increment, decrement, reset } from './state/counter.actions';

@Component({
  selector: 'app-counter',
  template: `
<h1>Counter: {{ count$ | async }}</h1>
<button (click)="onIncrement()">Increment</button>
<button (click)="onDecrement()">Decrement</button>
<button (click)="onReset()">Reset</button>
` }) export class CounterComponent { count$ = this.store.select('counter'); constructor(private store: Store<{ counter: number }>) {} onIncrement() {
this.store.dispatch(increment());
} onDecrement() {
this.store.dispatch(decrement());
} onReset() {
this.store.dispatch(reset());
} }

This component connects directly to the NgRx store using selectors and dispatches actions based on user interaction.


Using Selectors

Selectors allow you to query specific pieces of state efficiently. They are reusable and improve performance by avoiding unnecessary recalculations.

counter.selectors.ts

import { createSelector, createFeatureSelector } from '@ngrx/store';

export const selectCounterState = createFeatureSelector<number>('counter');

export const selectCount = createSelector(
  selectCounterState,
  (state: number) => state
);

Using the Selector in a Component:

count$ = this.store.select(selectCount);

Selectors are especially useful in large applications with nested state structures.


Handling Side Effects with NgRx Effects

Effects handle asynchronous operations like API calls or logging. They listen for actions and can dispatch new actions based on the results.

counter.effects.ts

import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { tap } from 'rxjs/operators';
import { increment, decrement } from './counter.actions';

@Injectable()
export class CounterEffects {
  logActions$ = createEffect(() =>
this.actions$.pipe(
  ofType(increment, decrement),
  tap(action =&gt; console.log('Action dispatched:', action))
),
{ dispatch: false }
); constructor(private actions$: Actions) {} }

Register Effects:

import { EffectsModule } from '@ngrx/effects';
import { CounterEffects } from './state/counter.effects';

@NgModule({
  imports: [
EffectsModule.forRoot(&#91;CounterEffects])
] }) export class AppModule {}

Effects make NgRx powerful for real-world apps where side effects are common.


Using NgRx DevTools

NgRx integrates with Redux DevTools, a Chrome extension for debugging state.

Setup:

import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from '../environments/environment';

@NgModule({
  imports: [
StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: environment.production })
] }) export class AppModule {}

Now you can view state transitions, actions, and time-travel through your application’s history.


Feature Modules with NgRx

Large apps often need feature-level state management. You can configure a store for specific modules.

feature.module.ts

import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { counterReducer } from './state/counter.reducer';
import { CounterEffects } from './state/counter.effects';

@NgModule({
  imports: [
StoreModule.forFeature('counterFeature', counterReducer),
EffectsModule.forFeature(&#91;CounterEffects])
] }) export class CounterFeatureModule {}

This approach keeps the application modular and scalable.


Managing Complex State

NgRx can handle deeply nested states using structured objects.

Example:

export interface AppState {
  user: {
name: string;
email: string;
}; products: string[]; }

You can define separate reducers for each slice of state and combine them using StoreModule.forRoot().


Async Operations with NgRx Effects

Let’s see an example of fetching data from an API using Effects.

products.actions.ts

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

export const loadProducts = createAction('[Products] Load');
export const loadProductsSuccess = createAction('[Products] Load Success', props<{ products: any[] }>());
export const loadProductsFailure = createAction('[Products] Load Failure', props<{ error: string }>());

products.effects.ts

import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { HttpClient } from '@angular/common/http';
import { mergeMap, map, catchError, of } from 'rxjs';
import { loadProducts, loadProductsSuccess, loadProductsFailure } from './products.actions';

@Injectable()
export class ProductsEffects {
  loadProducts$ = createEffect(() =>
this.actions$.pipe(
  ofType(loadProducts),
  mergeMap(() =&gt;
    this.http.get&lt;any&#91;]&gt;('/api/products').pipe(
      map(products =&gt; loadProductsSuccess({ products })),
      catchError(error =&gt; of(loadProductsFailure({ error: error.message })))
    )
  )
)
); constructor(private actions$: Actions, private http: HttpClient) {} }

products.reducer.ts

import { createReducer, on } from '@ngrx/store';
import { loadProducts, loadProductsSuccess, loadProductsFailure } from './products.actions';

export interface ProductsState {
  products: any[];
  loading: boolean;
  error: string | null;
}

export const initialState: ProductsState = {
  products: [],
  loading: false,
  error: null
};

export const productsReducer = createReducer(
  initialState,
  on(loadProducts, state => ({ ...state, loading: true })),
  on(loadProductsSuccess, (state, { products }) => ({ ...state, loading: false, products })),
  on(loadProductsFailure, (state, { error }) => ({ ...state, loading: false, error }))
);

Using Selectors for Data Access

import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ProductsState } from './products.reducer';

export const selectProductsState = createFeatureSelector<ProductsState>('products');

export const selectAllProducts = createSelector(
  selectProductsState,
  (state) => state.products
);

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

Now, components can use:

this.products$ = this.store.select(selectAllProducts);

Benefits of Using NgRx

  1. Predictable State Management – Every change is explicit and traceable.
  2. Centralized Data Flow – All data passes through a single source of truth.
  3. Improved Debugging – With DevTools, tracking actions and states is simple.
  4. Reactive Integration – Seamlessly works with RxJS for reactive applications.
  5. Testability – Each piece (actions, reducers, effects) can be unit tested.

Testing NgRx Components

NgRx makes testing easy because of its pure functions and predictable flow.

Testing a Reducer:

import { counterReducer, initialState } from './counter.reducer';
import { increment } from './counter.actions';

describe('Counter Reducer', () => {
  it('should increment the counter', () => {
const newState = counterReducer(initialState, increment());
expect(newState).toBe(1);
}); });

Best Practices for NgRx

  1. Keep your state shape minimal and normalized.
  2. Use selectors for all state access.
  3. Avoid placing non-serializable data in the store.
  4. Split large stores into feature modules.
  5. Handle side effects only in Effects.
  6. Use createFeatureSelector and createSelector for reusability.
  7. Combine NgRx with EntityAdapter for managing collections efficiently.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *