NgRx Store Study Guide
This guide teaches NgRx using a single e-commerce app with Products, Cart, Auth, and Orders. Every topic includes actual NgRx source code and a component wiring example so you can see the pattern in real Angular 16-17 apps.
Main data flow
Async side-effect flow
Single source of truth for app state.
Events describing what happened.
Pure functions that calculate next state.
Reusable queries over state.
Side-effect handlers for async work.
1. NgRx Big Picture
FoundationNgRx solves the moment when state stops being easy to manage with plain services and Subjects. In an e-commerce app, product lists, cart totals, auth state, and order history all need predictable updates without components mutating shared objects in random places.
Use NgRx when many screens depend on the same state, side effects must be consistent, and debugging state transitions matters. Stay with service-based state when a feature is small, isolated, and not shared widely.
// store/index.ts
// Root state mirrors the e-commerce app's business domains.
export interface AppState {
products: ProductsState;
cart: CartState;
auth: AuthState;
orders: OrdersState;
}
// NgRx works best when state is organized by business feature,
// not by UI component tree depth.
export const reducers: ActionReducerMap<AppState> = {
products: productsReducer,
cart: cartReducer,
auth: authReducer,
orders: ordersReducer,
};@Component({
selector: 'app-header-cart-summary',
template: `
<button (click)="openCart()">
Cart ({{ cartCount$ | async }})
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeaderCartSummaryComponent {
// Component does not know how totals are computed.
// It only selects derived state from the store.
readonly cartCount$ = this.store.select(selectCartItemCount);
constructor(private readonly store: Store) {}
openCart(): void {
this.store.dispatch(UiActions.cartDrawerOpened());
}
}2. Setting Up NgRx Store
BootstrapSetup differs slightly between classic NgModule apps and standalone Angular apps. The core idea stays the same: register root reducers once, then attach feature state near the feature that owns it.
For Angular 16-17, you should know both StoreModule.forRoot() and provideStore() because real teams often have mixed codebases during migration.
// Install
// npm i @ngrx/store @ngrx/effects @ngrx/store-devtools @ngrx/entity @ngrx/router-store
// app.module.ts
@NgModule({
imports: [
BrowserModule,
StoreModule.forRoot(reducers),
EffectsModule.forRoot([AuthEffects]),
StoreModule.forFeature(productsFeatureKey, productsReducer),
],
})
export class AppModule {}
// main.ts - standalone Angular 16-17
bootstrapApplication(AppComponent, {
providers: [
provideStore(reducers),
provideEffects([AuthEffects, ProductsEffects]),
provideState(productsFeature),
],
});// app.config.ts for standalone apps can keep registration close to bootstrap.
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideRouter(routes),
provideStore(),
provideState(cartFeature),
provideEffects([CartEffects]),
],
};
@Component({
selector: 'app-root',
template: `<app-shell />`,
})
export class AppComponent {}createFeature() with provideState() for cleaner standalone setup and built-in selectors.3. Actions
EventsActions describe facts that happened in the system. In a shopping app, “Add To Cart Clicked” or “Products Load Succeeded” is clearer than vague commands because you can reconstruct the business story from the action log.
Think of actions as domain events, not direct instructions to reducers. That keeps features decoupled and makes effects easier to reason about.
// store/actions/cart.actions.ts
export const addToCart = createAction(
'[Product List] Add To Cart Clicked',
props<{ productId: string }>()
);
export const cartCleared = createAction('[Checkout Page] Cart Cleared');
export const ProductsApiActions = createActionGroup({
source: 'Products API',
events: {
'Load Started': emptyProps(),
'Load Succeeded': props<{ products: Product[] }>(),
'Load Failed': props<{ error: string }>(),
},
});
// Event style is better than command style because multiple parts
// of the app can react to the same business event.@Component({
selector: 'app-product-card',
template: `
<article>
<h3>{{ product.name }}</h3>
<button (click)="add()">Add to cart</button>
</article>
`,
})
export class ProductCardComponent {
@Input({ required: true }) product!: Product;
constructor(private readonly store: Store) {}
add(): void {
// Dispatch from UI boundary and keep payload small and serializable.
this.store.dispatch(addToCart({ productId: this.product.id }));
}
}[Source] Event naming pattern consistently, because DevTools become much easier to scan.4. Reducers
Pure State UpdatesReducers are pure functions that return new state objects from old state plus an action. They are the safest place to encode business rules like cart quantity increments or auth logout resets because no async logic lives there.
Immutability matters because selectors and Angular change detection rely on new references. If you mutate arrays or nested objects in place, state changes become invisible and bugs get subtle.
export interface CartItem {
productId: string;
quantity: number;
}
export interface CartState {
items: CartItem[];
couponCode: string | null;
}
export const initialCartState: CartState = {
items: [],
couponCode: null,
};
export const cartReducer = createReducer(
initialCartState,
on(addToCart, (state, { productId }) => {
const existing = state.items.find(item => item.productId === productId);
if (existing) {
return {
...state,
items: state.items.map(item =>
item.productId === productId
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return {
...state,
items: [...state.items, { productId, quantity: 1 }],
};
}),
on(cartCleared, logoutSucceeded, () => initialCartState)
);
// Never do this: state.items.push(...)
// Mutation breaks memoization and predictable state history.@Component({
selector: 'app-cart-page',
template: `
<ng-container *ngIf="items$ | async as items">
<div *ngFor="let item of items">{{ item.productId }} x {{ item.quantity }}</div>
</ng-container>
<button (click)="clearCart()">Clear cart</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CartPageComponent {
readonly items$ = this.store.select(selectCartItems);
constructor(private readonly store: Store) {}
clearCart(): void {
this.store.dispatch(cartCleared());
}
}5. Selectors
Read ModelSelectors are how components read state without knowing reducer structure. They also centralize derivation logic, such as combining product entities with cart quantities to show a rich cart view model.
Memoization means the projector function only recomputes when an input selector changes reference. That keeps expensive calculations stable and avoids unnecessary UI work.
export const selectCartState = createFeatureSelector<CartState>('cart');
export const selectProductsState = createFeatureSelector<ProductsState>('products');
export const selectCartItems = createSelector(
selectCartState,
state => state.items
);
export const selectProductEntities = createSelector(
selectProductsState,
state => state.entities
);
export const selectCartViewModels = createSelector(
selectCartItems,
selectProductEntities,
(items, entities) => items.map(item => ({
product: entities[item.productId],
quantity: item.quantity,
lineTotal: (entities[item.productId]?.price ?? 0) * item.quantity,
}))
);
export const selectCartGrandTotal = createSelector(
selectCartViewModels,
items => items.reduce((sum, item) => sum + item.lineTotal, 0)
);
// The projector is the last function. Keep it pure and deterministic.@Component({
selector: 'app-cart-summary',
template: `
<ng-container *ngIf="vm$ | async as vm">
<div *ngFor="let item of vm.items">
{{ item.product.name }} - {{ item.quantity }}
</div>
<strong>Total: {{ vm.total | currency:'USD' }}</strong>
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CartSummaryComponent {
readonly vm$ = combineLatest({
items: this.store.select(selectCartViewModels),
total: this.store.select(selectCartGrandTotal),
});
constructor(private readonly store: Store) {}
}6. Effects
Async Side EffectsEffects react to actions and run side effects like HTTP calls, router navigation, and notifications outside components. This keeps container components thin and makes async orchestration reusable and testable.
Operator choice matters: switchMap cancels stale work, concatMap queues tasks, exhaustMap ignores repeats during an active request, and mergeMap runs in parallel.
| Operator | Use it when |
|---|---|
| switchMap | Latest search or filter request should win. |
| concatMap | Order matters, such as saving sequential order updates. |
| exhaustMap | Ignore repeated login or checkout clicks until the first finishes. |
| mergeMap | Parallel work is acceptable, such as loading product details for several cards. |
@Injectable()
export class AuthEffects {
readonly login$ = createEffect(() =>
this.actions$.pipe(
ofType(loginSubmitted),
// exhaustMap prevents double-submit races on login.
exhaustMap(({ email, password }) =>
this.authApi.login(email, password).pipe(
map(user => loginSucceeded({ user })),
catchError(() => of(loginFailed({ error: 'Invalid credentials' })))
)
)
)
);
readonly loginSuccessNavigate$ = createEffect(
() =>
this.actions$.pipe(
ofType(loginSucceeded),
tap(() => this.router.navigate(['/products']))
),
{ dispatch: false }
);
constructor(
private readonly actions$: Actions,
private readonly authApi: AuthApiService,
private readonly router: Router
) {}
}@Component({
selector: 'app-login-form',
template: `
<form (ngSubmit)="submit()">
<input [(ngModel)]="email" name="email" />
<input [(ngModel)]="password" name="password" type="password" />
<button>Sign in</button>
</form>
<p *ngIf="error$ | async as error">{{ error }}</p>
`,
})
export class LoginFormComponent {
email = '';
password = '';
readonly error$ = this.store.select(selectAuthError);
constructor(private readonly store: Store) {}
submit(): void {
this.store.dispatch(loginSubmitted({ email: this.email, password: this.password }));
}
}7. HTTP Integration with Effects
Full CRUDA mature NgRx feature usually follows the same pattern for HTTP work: request action, API call in effect, success or failure action, then reducer updates loading and error flags. Once the pattern is established, CRUD screens become consistent and easy to extend.
In products management, your store should expose items, loading state, and error state so components can render spinners and error banners declaratively.
export const ProductsPageActions = createActionGroup({
source: 'Products Page',
events: {
'Load Requested': emptyProps(),
'Create Requested': props<{ product: ProductCreateDto }>(),
'Update Requested': props<{ id: string; changes: Partial<Product> }>(),
'Delete Requested': props<{ id: string }>(),
},
});
export const ProductsApiActions = createActionGroup({
source: 'Products API',
events: {
'Load Succeeded': props<{ products: Product[] }>(),
'Create Succeeded': props<{ product: Product }>(),
'Update Succeeded': props<{ product: Product }>(),
'Delete Succeeded': props<{ id: string }>(),
'Request Failed': props<{ error: string }>(),
},
});
export interface ProductsState {
items: Product[];
loading: boolean;
error: string | null;
}
export const initialProductsState: ProductsState = {
items: [],
loading: false,
error: null,
};
export const productsReducer = createReducer(
initialProductsState,
on(
ProductsPageActions.loadRequested,
ProductsPageActions.createRequested,
ProductsPageActions.updateRequested,
ProductsPageActions.deleteRequested,
state => ({ ...state, loading: true, error: null })
),
on(ProductsApiActions.loadSucceeded, (state, { products }) => ({ ...state, loading: false, items: products })),
on(ProductsApiActions.createSucceeded, (state, { product }) => ({ ...state, loading: false, items: [...state.items, product] })),
on(ProductsApiActions.updateSucceeded, (state, { product }) => ({
...state,
loading: false,
items: state.items.map(item => item.id === product.id ? product : item),
})),
on(ProductsApiActions.deleteSucceeded, (state, { id }) => ({
...state,
loading: false,
items: state.items.filter(item => item.id !== id),
})),
on(ProductsApiActions.requestFailed, (state, { error }) => ({ ...state, loading: false, error }))
);
@Injectable()
export class ProductsEffects {
load$ = createEffect(() => this.actions$.pipe(
ofType(ProductsPageActions.loadRequested),
switchMap(() => this.api.getAll().pipe(
map(products => ProductsApiActions.loadSucceeded({ products })),
catchError(() => of(ProductsApiActions.requestFailed({ error: 'Could not load products' })))
))
));
create$ = createEffect(() => this.actions$.pipe(
ofType(ProductsPageActions.createRequested),
concatMap(({ product }) => this.api.create(product).pipe(
map(created => ProductsApiActions.createSucceeded({ product: created })),
catchError(() => of(ProductsApiActions.requestFailed({ error: 'Create failed' })))
))
));
update$ = createEffect(() => this.actions$.pipe(
ofType(ProductsPageActions.updateRequested),
concatMap(({ id, changes }) => this.api.update(id, changes).pipe(
map(product => ProductsApiActions.updateSucceeded({ product })),
catchError(() => of(ProductsApiActions.requestFailed({ error: 'Update failed' })))
))
));
delete$ = createEffect(() => this.actions$.pipe(
ofType(ProductsPageActions.deleteRequested),
mergeMap(({ id }) => this.api.delete(id).pipe(
map(() => ProductsApiActions.deleteSucceeded({ id })),
catchError(() => of(ProductsApiActions.requestFailed({ error: 'Delete failed' })))
))
));
constructor(private readonly actions$: Actions, private readonly api: ProductsApiService) {}
}@Component({
selector: 'app-products-page',
template: `
<button (click)="reload()">Reload</button>
<div *ngIf="loading$ | async" class="spinner">Loading...</div>
<p *ngIf="error$ | async as error" class="error">{{ error }}</p>
<app-product-table
[products]="products$ | async"
(create)="create($event)"
(update)="update($event)"
(delete)="remove($event)"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductsPageComponent implements OnInit {
readonly products$ = this.store.select(selectAllProducts);
readonly loading$ = this.store.select(selectProductsLoading);
readonly error$ = this.store.select(selectProductsError);
constructor(private readonly store: Store) {}
ngOnInit(): void {
this.store.dispatch(ProductsPageActions.loadRequested());
}
reload(): void { this.store.dispatch(ProductsPageActions.loadRequested()); }
create(product: ProductCreateDto): void { this.store.dispatch(ProductsPageActions.createRequested({ product })); }
update(event: { id: string; changes: Partial<Product> }): void { this.store.dispatch(ProductsPageActions.updateRequested(event)); }
remove(id: string): void { this.store.dispatch(ProductsPageActions.deleteRequested({ id })); }
}8. Entity State with @ngrx/entity
Normalized CollectionsEntity state gives you normalized storage for collections so updates stay fast and selectors become cleaner. Instead of manually managing arrays and lookup maps, you use an adapter that handles common immutable operations for you.
This is especially useful for products and orders because you frequently need “all items”, “one item by id”, and “count” from the same feature state.
export interface Product {
id: string;
name: string;
price: number;
stock: number;
}
export interface ProductsState extends EntityState<Product> {
loading: boolean;
error: string | null;
}
export const productsAdapter = createEntityAdapter<Product>({
selectId: product => product.id,
sortComparer: (a, b) => a.name.localeCompare(b.name),
});
export const initialState: ProductsState = productsAdapter.getInitialState({
loading: false,
error: null,
});
export const productsReducer = createReducer(
initialState,
on(ProductsApiActions.loadSucceeded, (state, { products }) =>
productsAdapter.setAll(products, { ...state, loading: false })
),
on(ProductsApiActions.createSucceeded, (state, { product }) =>
productsAdapter.addOne(product, { ...state, loading: false })
),
on(ProductsApiActions.updateSucceeded, (state, { product }) =>
productsAdapter.updateOne({ id: product.id, changes: product }, { ...state, loading: false })
),
on(ProductsApiActions.deleteSucceeded, (state, { id }) =>
productsAdapter.removeOne(id, { ...state, loading: false })
)
);
export const {
selectAll,
selectEntities,
selectIds,
selectTotal,
} = productsAdapter.getSelectors();const selectProductsFeature = createFeatureSelector<ProductsState>('products');
export const selectAllProducts = createSelector(selectProductsFeature, selectAll);
export const selectProductEntities = createSelector(selectProductsFeature, selectEntities);
export const selectProductsCount = createSelector(selectProductsFeature, selectTotal);
@Component({
selector: 'app-admin-products-header',
template: `
<div>
{{ total$ | async }} products loaded
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminProductsHeaderComponent {
readonly total$ = this.store.select(selectProductsCount);
constructor(private readonly store: Store) {}
}9. Component Store (@ngrx/component-store)
Local Reactive StateComponentStore is ideal for state that is complex enough to benefit from NgRx patterns but not global enough for the app-wide store. Think product table pagination, filter panels, modal wizards, or local cart preview state.
You still get selectors, updaters, and effects, but scoped to a component or feature shell.
interface ProductsTableState {
page: number;
pageSize: number;
search: string;
loading: boolean;
}
@Injectable()
export class ProductsTableStore extends ComponentStore<ProductsTableState> {
constructor(private readonly api: ProductsApiService) {
super({ page: 1, pageSize: 10, search: '', loading: false });
}
readonly page$ = this.select(state => state.page);
readonly vm$ = this.select(state => state);
readonly setSearch = this.updater((state, search: string) => ({
...state,
search,
page: 1,
}));
readonly nextPage = this.updater(state => ({
...state,
page: state.page + 1,
}));
readonly loadPage = this.effect(trigger$ =>
trigger$.pipe(
tap(() => this.patchState({ loading: true })),
switchMap(() =>
this.api.getPaged(this.get().page, this.get().pageSize, this.get().search).pipe(
tapResponse({
next: () => this.patchState({ loading: false }),
error: () => this.patchState({ loading: false }),
})
)
)
)
);
}@Component({
selector: 'app-products-table-shell',
providers: [ProductsTableStore],
template: `
<input (input)="search($any($event.target).value)" placeholder="Search products" />
<button (click)="next()">Next page</button>
<div *ngIf="vm$ | async as vm">Page: {{ vm.page }} | Loading: {{ vm.loading }}</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductsTableShellComponent {
readonly vm$ = this.store.vm$;
constructor(private readonly store: ProductsTableStore) {
this.store.loadPage();
}
search(term: string): void {
this.store.setSearch(term);
this.store.loadPage();
}
next(): void {
this.store.nextPage();
this.store.loadPage();
}
}10. Router Store (@ngrx/router-store)
Route StateRouter Store syncs Angular router state into NgRx so selectors can read route params, query params, and navigation info without injecting ActivatedRoute everywhere. This is useful when route state participates in effects or shared selectors.
For example, a product details effect can react to route changes and load the current product from the store pipeline.
@NgModule({
imports: [
StoreModule.forRoot(reducers),
StoreRouterConnectingModule.forRoot(),
],
})
export class AppModule {}
export interface AppState {
router: RouterReducerState;
}
export const {
selectRouteParams,
selectQueryParams,
selectRouteParam,
} = getRouterSelectors();
export const selectCurrentProductId = createSelector(
selectRouteParam('id'),
id => id
);
export const productDetailsRouteEntered = createAction('[Router] Product Details Entered');
export const navigateToCheckout = createAction('[Cart] Navigate To Checkout');
@Injectable()
export class RouterEffects {
navigateToCheckout$ = createEffect(
() => this.actions$.pipe(
ofType(navigateToCheckout),
tap(() => this.router.navigate(['/checkout']))
),
{ dispatch: false }
);
constructor(private readonly actions$: Actions, private readonly router: Router) {}
}@Component({
selector: 'app-product-details-page',
template: `
<ng-container *ngIf="productId$ | async as productId">
Current route product: {{ productId }}
</ng-container>
<button (click)="goCheckout()">Checkout</button>
`,
})
export class ProductDetailsPageComponent {
readonly productId$ = this.store.select(selectCurrentProductId);
readonly queryParams$ = this.store.select(selectQueryParams);
constructor(private readonly store: Store) {}
goCheckout(): void {
this.store.dispatch(navigateToCheckout());
}
}11. NgRx Signals Store (@ngrx/signals)
Angular 16-17Signals Store offers a more Angular-native way to model reactive state with signals and computed values. It feels lighter than the classic store and is especially attractive for local or feature-scoped state where full action streams may be unnecessary.
The classic store is still stronger for large event-driven architectures, advanced tooling, and strict global event tracing. Signals Store is simpler when you want direct methods and computed state.
export const CartSignalStore = signalStore(
{ providedIn: 'root' },
withState({
items: [] as CartItem[],
promoCode: '',
}),
withComputed(({ items }) => ({
totalItems: computed(() => items().reduce((sum, item) => sum + item.quantity, 0)),
})),
withMethods((store) => ({
add(productId: string) {
patchState(store, state => {
const existing = state.items.find(item => item.productId === productId);
if (existing) {
return {
items: state.items.map(item =>
item.productId === productId ? { ...item, quantity: item.quantity + 1 } : item
),
};
}
return { items: [...state.items, { productId, quantity: 1 }] };
});
},
})),
withHooks({
onInit() {
// Good place for initial feature setup when store is created.
console.log('CartSignalStore ready');
},
})
);
// Classic store is action-driven and centralized.
// Signals store is method-driven and signal-native.@Component({
selector: 'app-signal-cart-button',
template: `
<button (click)="add()">Add to cart</button>
<span>Items: {{ cartStore.totalItems() }}</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SignalCartButtonComponent {
readonly cartStore = inject(CartSignalStore);
@Input({ required: true }) productId!: string;
add(): void {
this.cartStore.add(this.productId);
}
}
// Compare this to classic NgRx where components dispatch actions and select observables.
// Signals Store gives synchronous signal reads and methods instead.12. Devtools & Debugging
ObservabilityDevtools make NgRx teachable because you can inspect every action, diff state transitions, and time-travel through the app. This is one of the biggest practical reasons teams choose NgRx over ad hoc service state.
You can also restrict Devtools in production and configure history depth to avoid performance issues in large apps.
@NgModule({
imports: [
StoreModule.forRoot(reducers),
StoreDevtoolsModule.instrument({
maxAge: 50,
logOnly: environment.production,
autoPause: true,
trace: !environment.production,
traceLimit: 25,
}),
],
})
export class AppModule {}
// DevTools let you inspect:
// 1. Action log
// 2. Previous vs next state
// 3. Time-travel replay
// 4. Selector assumptions during debugging@Component({
selector: 'app-debug-cart',
template: `
<button (click)="seed()">Seed cart</button>
<button (click)="clear()">Clear</button>
`,
})
export class DebugCartComponent {
constructor(private readonly store: Store) {}
seed(): void {
this.store.dispatch(addToCart({ productId: 'p-101' }));
this.store.dispatch(addToCart({ productId: 'p-102' }));
}
clear(): void {
this.store.dispatch(cartCleared());
}
// Use Redux DevTools browser extension to inspect these actions in order.
}13. Meta-Reducers
Reducer MiddlewareMeta-reducers wrap reducers and let you apply cross-cutting behavior to every state transition. Common uses are logging, hydration from local storage, and resetting the app on logout.
Think of them as reducer middleware: they intercept state calculation before the final next state is returned.
export function loggerMetaReducer(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
return (state, action) => {
const next = reducer(state, action);
console.debug('[NgRx]', action.type, { prev: state, next });
return next;
};
}
export function hydrationMetaReducer(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
return (state, action) => {
if (action.type === '@ngrx/store/init') {
const saved = localStorage.getItem('shop-state');
return saved ? JSON.parse(saved) : reducer(state, action);
}
const nextState = reducer(state, action);
localStorage.setItem('shop-state', JSON.stringify({ cart: nextState.cart }));
return nextState;
};
}
export function resetOnLogoutMetaReducer(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
return (state, action) => {
if (action.type === logoutSucceeded.type) {
// Passing undefined forces reducers back to initial state.
return reducer(undefined, { type: '@@INIT_AFTER_LOGOUT' });
}
return reducer(state, action);
};
}
export const metaReducers: MetaReducer<AppState>[] = [
loggerMetaReducer,
hydrationMetaReducer,
resetOnLogoutMetaReducer,
];@NgModule({
imports: [
StoreModule.forRoot(reducers, { metaReducers }),
],
})
export class AppModule {}
@Component({
selector: 'app-account-menu',
template: `<button (click)="logout()">Logout</button>`,
})
export class AccountMenuComponent {
constructor(private readonly store: Store) {}
logout(): void {
// A logout action can trigger both auth cleanup and full app reset.
this.store.dispatch(logoutSucceeded());
}
}14. Action Hygiene & Best Practices
MaintainabilityAction quality determines how understandable your store becomes over time. Good action hygiene means one action has one clear purpose, features do not depend on each other’s internal actions, and reusable patterns stay explicit instead of generic.
This is the difference between a store that scales and a store that turns into event soup.
// Good: one action, one business event.
export const checkoutSubmitted = createAction(
'[Checkout Page] Checkout Submitted',
props<{ paymentMethodId: string }>()
);
// Avoid coupling: cart feature should not depend on private admin actions.
export const orderPlacedSucceeded = createAction(
'[Orders API] Order Placed Succeeded',
props<{ order: Order }>()
);
// Reusable action factory when shape is repeated intentionally.
function createRequestStateActions(source: string) {
return createActionGroup({
source,
events: {
'Started': emptyProps(),
'Succeeded': emptyProps(),
'Failed': props<{ error: string }>(),
},
});
}
export const CartCouponActions = createRequestStateActions('Cart Coupon API');@Injectable({ providedIn: 'root' })
export class CheckoutFacade {
readonly processing$ = this.store.select(selectCheckoutProcessing);
constructor(private readonly store: Store) {}
submit(paymentMethodId: string): void {
// Facade prevents components from knowing too many action details.
this.store.dispatch(checkoutSubmitted({ paymentMethodId }));
}
}
@Component({
selector: 'app-checkout-button',
template: `<button (click)="submit()" [disabled]="processing$ | async">Place order</button>`,
})
export class CheckoutButtonComponent {
readonly processing$ = this.facade.processing$;
constructor(private readonly facade: CheckoutFacade) {}
submit(): void { this.facade.submit('pm_001'); }
}15. Facade Pattern
Abstraction LayerA facade is a service that hides NgRx details from components and exposes a small API of observables and methods. This keeps templates clean, lowers coupling, and makes store refactors easier because components do not care whether data comes from Store, ComponentStore, or Signals Store.
It also simplifies testing, because component tests can mock one facade instead of full store behavior.
@Injectable({ providedIn: 'root' })
export class CartFacade {
readonly items$ = this.store.select(selectCartViewModels);
readonly total$ = this.store.select(selectCartGrandTotal);
readonly count$ = this.store.select(selectCartItemCount);
constructor(private readonly store: Store) {}
load(): void {
this.store.dispatch(cartLoadedRequested());
}
add(productId: string): void {
this.store.dispatch(addToCart({ productId }));
}
remove(productId: string): void {
this.store.dispatch(removeFromCart({ productId }));
}
clear(): void {
this.store.dispatch(cartCleared());
}
}@Component({
selector: 'app-cart-drawer',
template: `
<div *ngFor="let item of items$ | async">
{{ item.product.name }}
<button (click)="remove(item.product.id)">Remove</button>
</div>
<strong>{{ total$ | async | currency:'USD' }}</strong>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CartDrawerComponent implements OnInit {
readonly items$ = this.facade.items$;
readonly total$ = this.facade.total$;
constructor(private readonly facade: CartFacade) {}
ngOnInit(): void {
this.facade.load();
}
remove(productId: string): void {
this.facade.remove(productId);
}
}16. Optimistic vs Pessimistic Updates
UX StrategyPessimistic updates wait for the server before changing store state, which is safer for high-risk operations like payment or inventory-sensitive actions. Optimistic updates change the UI immediately, then roll back on failure, which feels faster for low-risk actions like cart quantity tweaks or wishlist toggles.
The right choice depends on business cost of being wrong, not developer preference.
// PESSIMISTIC: wait for API, then commit.
export const cartQuantityChangeRequested = createAction(
'[Cart Page] Quantity Change Requested',
props<{ productId: string; quantity: number }>()
);
export const cartQuantityChangeSucceeded = createAction(
'[Cart API] Quantity Change Succeeded',
props<{ productId: string; quantity: number }>()
);
// OPTIMISTIC: update immediately, rollback if API fails.
export const cartQuantityChangedOptimistic = createAction(
'[Cart Page] Quantity Changed Optimistic',
props<{ productId: string; quantity: number; previousQuantity: number }>()
);
export const cartQuantityChangeRollback = createAction(
'[Cart API] Quantity Change Rollback',
props<{ productId: string; previousQuantity: number; error: string }>()
);
@Injectable()
export class CartEffects {
pessimistic$ = createEffect(() => this.actions$.pipe(
ofType(cartQuantityChangeRequested),
concatMap(({ productId, quantity }) => this.api.updateQuantity(productId, quantity).pipe(
map(() => cartQuantityChangeSucceeded({ productId, quantity })),
catchError(() => of(cartSyncFailed({ error: 'Could not update cart' })))
))
));
optimistic$ = createEffect(() => this.actions$.pipe(
ofType(cartQuantityChangedOptimistic),
mergeMap(({ productId, quantity, previousQuantity }) => this.api.updateQuantity(productId, quantity).pipe(
map(() => cartQuantitySynced({ productId, quantity })),
catchError(() => of(cartQuantityChangeRollback({ productId, previousQuantity, error: 'Server rejected update' })))
))
));
}
export const cartReducer = createReducer(
initialCartState,
on(cartQuantityChangeSucceeded, (state, { productId, quantity }) => ({
...state,
items: state.items.map(item => item.productId === productId ? { ...item, quantity } : item),
})),
on(cartQuantityChangedOptimistic, (state, { productId, quantity }) => ({
...state,
items: state.items.map(item => item.productId === productId ? { ...item, quantity } : item),
})),
on(cartQuantityChangeRollback, (state, { productId, previousQuantity }) => ({
...state,
items: state.items.map(item => item.productId === productId ? { ...item, quantity: previousQuantity } : item),
}))
);@Component({
selector: 'app-cart-line-item',
template: `
<button (click)="pessimisticUpdate()">Safe update</button>
<button (click)="optimisticUpdate()">Fast update</button>
`,
})
export class CartLineItemComponent {
@Input({ required: true }) item!: CartItem;
constructor(private readonly store: Store) {}
pessimisticUpdate(): void {
this.store.dispatch(cartQuantityChangeRequested({
productId: this.item.productId,
quantity: this.item.quantity + 1,
}));
}
optimisticUpdate(): void {
this.store.dispatch(cartQuantityChangedOptimistic({
productId: this.item.productId,
quantity: this.item.quantity + 1,
previousQuantity: this.item.quantity,
}));
}
}17. Performance Optimization
ScaleNgRx performs well when selectors stay pure, change detection is constrained, and components subscribe only to what they need. Angular 16-17 gives you more options here with selectSignal() and signal-friendly rendering patterns.
The biggest wins usually come from selector composition, OnPush components, and avoiding large template-level recomputation.
export const selectVisibleProducts = createSelector(
selectAllProducts,
selectCategoryFilter,
selectSearchTerm,
(products, category, search) =>
products.filter(product => {
const matchesCategory = !category || product.category === category;
const matchesSearch = !search || product.name.toLowerCase().includes(search.toLowerCase());
return matchesCategory && matchesSearch;
})
);
@Injectable({ providedIn: 'root' })
export class ProductsQuery {
constructor(private readonly store: Store) {}
// selectSignal gives synchronous signal reads in Angular 16-17.
readonly visibleProducts = this.store.selectSignal(selectVisibleProducts);
}@Component({
selector: 'app-products-grid',
template: `
<ng-container *ngIf="products$ | async as products">
<app-product-card *ngFor="let product of products; trackBy: trackById" [product]="product" />
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductsGridComponent {
readonly products$ = this.store.select(selectVisibleProducts);
readonly productsSignal = this.store.selectSignal(selectVisibleProducts);
constructor(private readonly store: Store) {}
trackById = (_: number, product: Product) => product.id;
}
// ngrxLet / let-style directives reduce repeated async pipes in large templates.
// OnPush plus memoized selectors is the default performance baseline.18. Testing NgRx
ConfidenceNgRx is testable because reducers are pure, selectors are deterministic, and effects can be driven by mock action streams. You can test each layer in isolation instead of spinning up a full Angular app.
For component tests, MockStore and selector overrides let you focus on rendering and dispatched actions without real reducers.
describe('cartReducer', () => {
it('increments quantity when item already exists', () => {
const state: CartState = { items: [{ productId: 'p1', quantity: 1 }], couponCode: null };
const next = cartReducer(state, addToCart({ productId: 'p1' }));
expect(next.items[0].quantity).toBe(2);
});
});
describe('selectCartGrandTotal', () => {
it('computes total with projector', () => {
const result = selectCartGrandTotal.projector([
{ lineTotal: 100 },
{ lineTotal: 50 },
] as any);
expect(result).toBe(150);
});
});
describe('ProductsEffects', () => {
let actions$: Observable<Action>;
let effects: ProductsEffects;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ProductsEffects,
provideMockActions(() => actions$),
{ provide: ProductsApiService, useValue: { getAll: () => cold('--a', { a: [{ id: 'p1' }] }) } },
],
});
effects = TestBed.inject(ProductsEffects);
});
it('loads products', () => {
actions$ = hot('-a', { a: ProductsPageActions.loadRequested() });
expect(effects.load$).toBeObservable(
cold('---b', { b: ProductsApiActions.loadSucceeded({ products: [{ id: 'p1' } as Product] }) })
);
});
});describe('CartSummaryComponent', () => {
let store: MockStore;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CartSummaryComponent],
providers: [provideMockStore()],
}).compileComponents();
store = TestBed.inject(MockStore);
store.overrideSelector(selectCartGrandTotal, 250);
store.overrideSelector(selectCartViewModels, [{ product: { name: 'Laptop' }, quantity: 1, lineTotal: 250 }] as any);
});
afterEach(() => {
// Reset memoized selectors between tests when needed.
selectCartGrandTotal.release();
selectCartViewModels.release();
});
it('renders total from store', () => {
const fixture = TestBed.createComponent(CartSummaryComponent);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('250');
});
});NgRx Quick Reference Cheat Sheet
createXxx functions
createAction()— define a typed event.createActionGroup()— group related events by source.createReducer()— define immutable state transitions.on()— map one or more actions to reducer logic.createSelector()— derive memoized state.createFeatureSelector()— select one feature slice.createFeature()— feature registration with generated selectors.createEffect()— respond to actions with side effects.createEntityAdapter()— normalized collection helpers.signalStore()— build signal-driven store APIs.
Common RxJS in Effects
ofType()— filter the action stream.switchMap()— cancel stale requests, keep latest.concatMap()— process requests in order.exhaustMap()— ignore repeats during active work.mergeMap()— run tasks in parallel.map()— transform API response into action.catchError()— convert errors into failure actions.tap()— non-dispatch side effects like navigation.
NgRx packages
npm i @ngrx/store— core store.npm i @ngrx/effects— async side effects.npm i @ngrx/store-devtools— debugging support.npm i @ngrx/entity— entity collection helpers.npm i @ngrx/router-store— router state integration.npm i @ngrx/component-store— local reactive state.npm i @ngrx/signals— signals-based state primitives.
Selector composition
- Feature selector → primitive slice selector → derived selector.
- Keep projectors pure and deterministic.
- Compose selectors instead of nesting store subscriptions.
- Return stable references whenever possible.
- Prefer derived view models over component-side mapping.