Skip to content

NgRx State Management: Enterprise-Ready Angular Architecture

Master NgRx for scalable Angular applications. Learn actions, reducers, effects, and selectors to build maintainable state management solutions.

By 6 min read
  • Angular
  • NgRx
  • State Management
  • TypeScript
  • RxJS
NgRx State Management: Enterprise-Ready Angular Architecture

NgRx provides a reactive state management solution for Angular applications using RxJS. After implementing it in production applications like ENGINE and Ship-Watch, I've seen how it scales beautifully for complex enterprise applications. While it has more boilerplate than Zustand, NgRx excels when you need predictable state updates, time-travel debugging, and strict architectural patterns.

Why NgRx?

NgRx offers:

  • Predictable state updates: Single source of truth with immutable updates
  • Time-travel debugging: Track every state change
  • Testability: Pure functions make testing straightforward
  • Scalability: Works well for large teams and complex applications
  • DevTools integration: Powerful debugging capabilities

Core Concepts

NgRx follows the Redux pattern with Angular-specific implementations:

  • Store: Single source of truth
  • Actions: Events that trigger state changes
  • Reducers: Pure functions that update state
  • Effects: Side effects (API calls, logging)
  • Selectors: Derived state and memoization

Basic Setup

Install NgRx:

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

Actions

Define actions to describe events:

TSX
// actions/user.actions.ts
import { createAction, props } from '@ngrx/store';

export const loadUsers = createAction('[User] Load Users');
export const loadUsersSuccess = createAction(
  '[User] Load Users Success',
  props<{ users: User[] }>()
);
export const loadUsersFailure = createAction(
  '[User] Load Users Failure',
  props<{ error: string }>()
);

export const addUser = createAction(
  '[User] Add User',
  props<{ user: User }>()
);

export const updateUser = createAction(
  '[User] Update User',
  props<{ id: string; changes: Partial<User> }>()
);

export const deleteUser = createAction(
  '[User] Delete User',
  props<{ id: string }>()
);

Reducers

Create reducers to handle state updates:

TSX
// reducers/user.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as UserActions from '../actions/user.actions';

export interface UserState {
  users: User[];
  loading: boolean;
  error: string | null;
}

export const initialState: UserState = {
  users: [],
  loading: false,
  error: null,
};

export const userReducer = createReducer(
  initialState,
  on(UserActions.loadUsers, (state) => ({
    ...state,
    loading: true,
    error: null,
  })),
  on(UserActions.loadUsersSuccess, (state, { users }) => ({
    ...state,
    users,
    loading: false,
  })),
  on(UserActions.loadUsersFailure, (state, { error }) => ({
    ...state,
    error,
    loading: false,
  })),
  on(UserActions.addUser, (state, { user }) => ({
    ...state,
    users: [...state.users, user],
  })),
  on(UserActions.updateUser, (state, { id, changes }) => ({
    ...state,
    users: state.users.map(user =>
      user.id === id ? { ...user, ...changes } : user
    ),
  })),
  on(UserActions.deleteUser, (state, { id }) => ({
    ...state,
    users: state.users.filter(user => user.id !== id),
  }))
);

Effects

Handle side effects like API calls:

TSX
// effects/user.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, catchError, switchMap } from 'rxjs/operators';
import * as UserActions from '../actions/user.actions';
import { UserService } from '../services/user.service';

@Injectable()
export class UserEffects {
  loadUsers$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.loadUsers),
      switchMap(() =>
        this.userService.getUsers().pipe(
          map(users => UserActions.loadUsersSuccess({ users })),
          catchError(error =>
            of(UserActions.loadUsersFailure({ error: error.message }))
          )
        )
      )
    )
  );

  addUser$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.addUser),
      switchMap(({ user }) =>
        this.userService.createUser(user).pipe(
          map(newUser => UserActions.addUserSuccess({ user: newUser })),
          catchError(error =>
            of(UserActions.addUserFailure({ error: error.message }))
          )
        )
      )
    )
  );

  constructor(
    private actions$: Actions,
    private userService: UserService
  ) {}
}

Selectors

Create selectors for derived state:

TSX
// selectors/user.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { UserState } from '../reducers/user.reducer';

export const selectUserState = createFeatureSelector<UserState>('users');

export const selectAllUsers = createSelector(
  selectUserState,
  (state) => state.users
);

export const selectUsersLoading = createSelector(
  selectUserState,
  (state) => state.loading
);

export const selectUsersError = createSelector(
  selectUserState,
  (state) => state.error
);

export const selectUserById = (id: string) => createSelector(
  selectAllUsers,
  (users) => users.find(user => user.id === id)
);

export const selectActiveUsers = createSelector(
  selectAllUsers,
  (users) => users.filter(user => user.active)
);

App Module Configuration

Register store, effects, and devtools:

TSX
// app.module.ts
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { userReducer } from './reducers/user.reducer';
import { UserEffects } from './effects/user.effects';

@NgModule({
  imports: [
    StoreModule.forRoot({ users: userReducer }),
    EffectsModule.forRoot([UserEffects]),
    StoreDevtoolsModule.instrument({
      maxAge: 25,
      logOnly: environment.production,
    }),
  ],
})
export class AppModule {}

Using in Components

Dispatch actions and select state:

TSX
// components/user-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import * as UserActions from '../actions/user.actions';
import * as UserSelectors from '../selectors/user.selectors';
import { User } from '../models/user.model';

@Component({
  selector: 'app-user-list',
  template: `
    <div *ngIf="loading$ | async">Loading...</div>
    <div *ngIf="error$ | async as error">{{ error }}</div>
    <div *ngFor="let user of users$ | async">
      {{ user.name }}
    </div>
    <button (click)="loadUsers()">Load Users</button>
  `,
})
export class UserListComponent implements OnInit {
  users$: Observable<User[]> = this.store.select(UserSelectors.selectAllUsers);
  loading$: Observable<boolean> = this.store.select(UserSelectors.selectUsersLoading);
  error$: Observable<string | null> = this.store.select(UserSelectors.selectUsersError);

  constructor(private store: Store) {}

  ngOnInit(): void {
    this.loadUsers();
  }

  loadUsers(): void {
    this.store.dispatch(UserActions.loadUsers());
  }

  addUser(user: User): void {
    this.store.dispatch(UserActions.addUser({ user }));
  }
}

Entity Adapter

For collections, use EntityAdapter:

TSX
// reducers/user.reducer.ts
import { createEntityAdapter, EntityState } from '@ngrx/entity';

export interface UserState extends EntityState<User> {
  loading: boolean;
  error: string | null;
}

export const userAdapter = createEntityAdapter<User>({
  selectId: (user: User) => user.id,
  sortComparer: (a: User, b: User) => a.name.localeCompare(b.name),
});

export const initialState: UserState = userAdapter.getInitialState({
  loading: false,
  error: null,
});

export const userReducer = createReducer(
  initialState,
  on(UserActions.loadUsersSuccess, (state, { users }) =>
    userAdapter.setAll(users, { ...state, loading: false })
  ),
  on(UserActions.addUser, (state, { user }) =>
    userAdapter.addOne(user, state)
  ),
  on(UserActions.updateUser, (state, { id, changes }) =>
    userAdapter.updateOne({ id, changes }, state)
  ),
  on(UserActions.deleteUser, (state, { id }) =>
    userAdapter.removeOne(id, state)
  )
);

// Selectors with EntityAdapter
export const {
  selectAll: selectAllUsers,
  selectEntities: selectUserEntities,
  selectIds: selectUserIds,
  selectTotal: selectUserTotal,
} = userAdapter.getSelectors(selectUserState);

Facade Pattern

Create facades to simplify component interactions:

TSX
// facades/user.facade.ts
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import * as UserActions from '../actions/user.actions';
import * as UserSelectors from '../selectors/user.selectors';
import { User } from '../models/user.model';

@Injectable({ providedIn: 'root' })
export class UserFacade {
  users$: Observable<User[]> = this.store.select(UserSelectors.selectAllUsers);
  loading$: Observable<boolean> = this.store.select(UserSelectors.selectUsersLoading);
  error$: Observable<string | null> = this.store.select(UserSelectors.selectUsersError);

  constructor(private store: Store) {}

  loadUsers(): void {
    this.store.dispatch(UserActions.loadUsers());
  }

  addUser(user: User): void {
    this.store.dispatch(UserActions.addUser({ user }));
  }

  updateUser(id: string, changes: Partial<User>): void {
    this.store.dispatch(UserActions.updateUser({ id, changes }));
  }

  deleteUser(id: string): void {
    this.store.dispatch(UserActions.deleteUser({ id }));
  }
}

Best Practices

  1. Use EntityAdapter: For collections, EntityAdapter reduces boilerplate
  2. Create facades: Simplify component interactions
  3. Use selectors: Memoize derived state
  4. Keep reducers pure: No side effects in reducers
  5. Handle errors: Always handle error cases in effects
  6. Use DevTools: Leverage NgRx DevTools for debugging
  7. Split by feature: Organize store by feature modules

Real-World Impact

In production applications, NgRx has provided:

  • Predictable state management for complex workflows
  • Excellent debugging capabilities with DevTools
  • Better testability with pure functions
  • Scalable architecture for large teams
  • Time-travel debugging for issue resolution

NgRx is powerful for enterprise Angular applications where predictability, testability, and scalability are crucial. While it has more boilerplate than simpler solutions, the benefits become clear as applications grow in complexity.

Keep reading