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.