Introduction
In 2026, enterprise Angular apps demand robust state management to handle growing complexity: async data, multiple user interactions, and scalability. NgRx, inspired by Redux, remains the gold standard, enhanced by Signals (Angular 16+) for zone-less fine-grained reactivity and Standalone Components (Angular 14+) for a module-free architecture.
This expert tutorial guides you step-by-step to build a user management app (async CRUD): API fetching, add/delete with optimistic updates, loading/error states. You'll end up with a production-ready structure optimized for lazy-loading and testing. Why it matters: Without it, your Angular apps suffer from prop drilling, memory leaks, and debugging hell. Bookmark this for your critical projects. (142 words)
Prerequisites
- Node.js 20+ and npm 10+
- Angular CLI 18+ (
npm install -g @angular/cli@18) - Advanced TypeScript and RxJS mastery (operators like
switchMap,catchError) - HTTP and state management knowledge (Redux-like patterns)
- Editor like VS Code with Angular Language Service extensions
Creating the Standalone Project
ng new users-app --standalone --routing --style=scss --package-manager=npm
cd users-app
generate @angular/common/http
ng add @ngrx/store@18
ng add @ngrx/effects@18
ng add @ngrx/store-devtools@18
npm install
ng serveThis command sets up an Angular 18+ project with standalone components enabled by default, routing, and SCSS. We add HttpClient for API calls, then NgRx Store, Effects, and DevTools. ng serve starts the dev server at http://localhost:4200. Pitfall: Without --standalone, you fall back to the old polluting NgModule paradigm.
Defining Models and Interfaces
Before the store, define your domain models. Create a src/app/models folder for user.model.ts. This centralizes types, avoids duplication, and makes evolution easier.
User Model
export interface User {
id: number;
name: string;
email: string;
}
export interface UserState {
users: User[];
selectedUser: User | null;
loading: boolean;
error: string | null;
}
export const initialState: UserState = {
users: [],
selectedUser: null,
loading: false,
error: null
};User interface for API data and UserState for the NgRx slice. initialState provides the default immutable state. Think of it as an immutable blueprint for your data, preventing accidental mutations that break Redux predictability.
NgRx Actions
import { createAction, props } from '@ngrx/store';
import { User } from '../models/user.model';
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: Omit<User, 'id'> }>()
);
export const addUserSuccess = createAction(
'[User] Add User Success',
props<{ user: User }>()
);
export const deleteUser = createAction(
'[User] Delete User',
props<{ id: number }>()
);
export const deleteUserSuccess = createAction(
'[User] Delete User Success',
props<{ id: number }>()
);Actions created with createAction and props for typed payloads. [Feature] Event convention for DevTools traceability. Pitfall: Without typed props, TypeScript loses safety; always name explicitly for easier debugging.
Configuring the Main Store
Integrate NgRx into the bootstrap via app.config.ts. Use provideStore with mapped reducers. Signals will be used in selectors for optimal reactivity.
app.config.ts Configuration
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import * as userReducer from './store/user.reducer';
import { UserEffects } from './store/user.effects';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideStore({
users: userReducer.userReducer
}),
provideEffects([UserEffects]),
provideStoreDevtools({ maxAge: 25 })
]
};Standalone bootstrap without a cluttered main.ts. provideStore maps the 'users' reducer; Effects auto-register. DevTools limited to 25 actions for performance. Analogy: Centralized injection like an electrical hub, scalable for 10+ features.
User Reducer
import { createReducer, on } from '@ngrx/store';
import { initialState } from '../models/user.model';
import * as UserActions from './user.actions';
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, loading: false, error })),
on(UserActions.addUserSuccess, (state, { user }) => ({ ...state, users: [...state.users, user] })),
on(UserActions.deleteUserSuccess, (state, { id }) => ({
...state,
users: state.users.filter(u => u.id !== id)
}))
);
export function reducer(state: any, action: any) {
return userReducer(state, action);
}Pure reducer function via createReducer and on(). Immutable spread { ...state } is mandatory. Optimistic add/delete handled here via Effects. Pitfall: Forgetting immutable filter leads to hidden mutations.
Effects for API Calls
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError, switchMap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import * as UserActions from './user.actions';
@Injectable()
export class UserEffects {
constructor(private actions$: Actions, private http: HttpClient) {}
loadUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.loadUsers),
switchMap(() =>
this.http.get<User[]>('https://jsonplaceholder.typicode.com/users?_limit=5').pipe(
map(users => UserActions.loadUsersSuccess({ users })),
catchError(error => of(UserActions.loadUsersFailure({ error: error.message })))
)
)
)
);
addUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.addUser),
mergeMap(({ user }) =>
this.http.post<User>('https://jsonplaceholder.typicode.com/users', user).pipe(
map(newUser => UserActions.addUserSuccess({ user: { ...newUser, id: Date.now() } })),
catchError(error => of(UserActions.loadUsersFailure({ error: error.message })))
)
)
), { dispatch: true }
);
deleteUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.deleteUser),
mergeMap(({ id }) =>
this.http.delete(`https://jsonplaceholder.typicode.com/users/${id}`).pipe(
map(() => UserActions.deleteUserSuccess({ id })),
catchError(error => of(UserActions.loadUsersFailure({ error: error.message })))
)
)
)
);
Effects handle HTTP side effects with createEffect. switchMap cancels concurrent requests; catchError propagates errors. JSONPlaceholder API mocked (5 users limit). Pitfall: Without { dispatch: true } for add, no success dispatch.
Selectors with Signals
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { UserState } from '../models/user.model';
const getUserState = createFeatureSelector<UserState>('users');
export const selectUsers = createSelector(getUserState, state => state.users);
export const selectLoading = createSelector(getUserState, state => state.loading);
export const selectError = createSelector(getUserState, state => state.error);
export const selectUsersCount = createSelector(selectUsers, users => users.length);Memoized selectors avoid unnecessary recalculations. createFeatureSelector targets the 'users' slice. In 2026, pair with toSignal(selectUsers) in components for signal-based reactivity. Analogy: Smart cache, like a global TS memo.
UsersList Component
Create the main component that consumes the store. Use inject(Store) and Signals for reactive bindings.
UsersList Component
import { Component, inject, signal, effect } from '@angular/core';
import { Store } from '@ngrx/store';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import * as UserActions from '../store/user.actions';
import { selectUsers, selectLoading, selectError } from '../store/user.selectors';
import { User } from '../models/user.model';
@Component({
selector: 'app-users-list',
standalone: true,
imports: [CommonModule, HttpClientModule, ReactiveFormsModule],
template: `
<div>
<h2>Liste des Users ({{ usersCount() }})</h2>
<button (click)="loadUsers()" [disabled]="loading()">Charger</button>
@if (loading()) { <p>Chargement...</p> }
@if (error()) { <p style="color:red">Erreur: {{ error() }}</p> }
<form (ngSubmit)="addUser()">
<input [formControl]="nameCtrl" placeholder="Nom">
<input [formControl]="emailCtrl" placeholder="Email">
<button type="submit" [disabled]="form.invalid">Ajouter</button>
</form>
<ul>
@for (user of users(); track user.id) {
<li>{{ user.name }} - {{ user.email }}
<button (click)="deleteUser(user.id)">Supprimer</button>
</li>
}
</ul>
</div>
`
})
export class UsersListComponent {
private store = inject(Store);
users = toSignal(this.store.select(selectUsers), { initialValue: [] });
loading = toSignal(this.store.select(selectLoading));
error = toSignal(this.store.select(selectError));
usersCount = signal(0);
nameCtrl = new FormControl('', [Validators.required]);
emailCtrl = new FormControl('', [Validators.required, Validators.email]);
form = new FormGroup({ name: this.nameCtrl, email: this.emailCtrl });
constructor() {
effect(() => {
this.usersCount.set(this.users().length);
});
}
loadUsers() {
this.store.dispatch(UserActions.loadUsers());
}
addUser() {
if (this.form.valid) {
this.store.dispatch(UserActions.addUser({ user: this.form.value as any }));
this.form.reset();
}
}
deleteUser(id: number) {
this.store.dispatch(UserActions.deleteUser({ id }));
}
}Standalone component with inline template for simplicity. toSignal converts RxJS selectors to reactive Signals (Angular 16+). Native @for/@if control flow. Effect for derived signal. Pitfall: Without initialValue, it crashes on first render.
Routes and App Component
import { Routes } from '@angular/router';
import { UsersListComponent } from './users-list/users-list.component';
export const routes: Routes = [
{ path: '', component: UsersListComponent },
{ path: '**', redirectTo: '' }
];Simple standalone routes. Integrate the generated UsersListComponent via ng g c users-list --standalone. App bootstraps everything via config.
Best Practices
- Strict Immutability: Always use
{ ...state, prop: newValue }orimmerfor complexity. - Feature-Based Structure:
store/user/folders with actions/reducer/effects/selectors. - Signals Everywhere:
toSignalin components avoids perf-drainingasyncpipe. - Lazy-Loading Features:
loadChildrenwithprovideStateper feature. - Testing: Mock Store/Effects with
provideMockStorein specs.
Common Errors to Avoid
- Direct Mutations:
state.users.push()breaks DevTools time-travel. - Effects Without switchMap: Concurrent requests explode (use
exhaustMapfor forms). - Non-Memoized Selectors: Unnecessary recalcs tank perf (profile with DevTools).
- Forgetting initialState:
toSignalwithout fallback = SSR hydration errors.
Next Steps
- Official Docs: NgRx.io
- Angular Signals Deep Dive: angular.dev/signals
- Expert Training: Check out our advanced Angular courses at Learni
- GitHub Repo Example: Fork this tutorial and add NgRx Router Guards + Resolvers.