Redux Pattern and NgRx — Actions, Reducers and Store

NgRx implements the Redux pattern for Angular — a predictable state container where the entire application state lives in a single immutable store. Components never mutate state directly; they dispatch actions (plain objects describing what happened). Reducers are pure functions that produce the next state from the current state and an action. Selectors read and derive data from the store. This explicit, traceable data flow is invaluable for complex applications but is overkill for simple ones — understanding when to use NgRx vs service-based Signals is as important as understanding NgRx itself.

NgRx Store Setup

// npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/component-store

// ── app.config.ts — configure NgRx ────────────────────────────────────────
import { provideStore }   from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';

export const appConfig: ApplicationConfig = {
  providers: [
    provideStore(),            // empty root store (feature states registered separately)
    provideEffects(),          // root effects (empty — feature effects registered separately)
    provideStoreDevtools({     // Redux DevTools browser extension
      maxAge: 25,
      logOnly: !isDevMode(),
    }),
  ],
};

// ── Feature: posts — actions ──────────────────────────────────────────────
// src/app/features/posts/store/posts.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';

export const PostsActions = createActionGroup({
  source: 'Posts',          // prefix: "[Posts]"
  events: {
    // Load list
    'Load Posts':         props<{ page: number; size: number }>(),
    'Load Posts Success': props<{ posts: PostSummaryDto[]; total: number }>(),
    'Load Posts Failure': props<{ error: string }>(),
    // Create
    'Create Post':         props<{ request: CreatePostRequest }>(),
    'Create Post Success': props<{ post: PostDto }>(),
    'Create Post Failure': props<{ error: string }>(),
    // Delete
    'Delete Post':         props<{ id: number }>(),
    'Delete Post Success': props<{ id: number }>(),
    'Delete Post Failure': props<{ error: string }>(),
  },
});

// ── Feature: posts — state shape ──────────────────────────────────────────
export interface PostsState {
  posts:    PostSummaryDto[];
  total:    number;
  loading:  boolean;
  saving:   boolean;
  error:    string | null;
}

const initialState: PostsState = {
  posts:   [],
  total:   0,
  loading: false,
  saving:  false,
  error:   null,
};

// ── Feature: posts — reducer ──────────────────────────────────────────────
import { createFeature, createReducer, on } from '@ngrx/store';

export const postsFeature = createFeature({
  name: 'posts',
  reducer: createReducer(
    initialState,

    on(PostsActions.loadPosts, state => ({
      ...state, loading: true, error: null,
    })),
    on(PostsActions.loadPostsSuccess, (state, { posts, total }) => ({
      ...state, posts, total, loading: false,
    })),
    on(PostsActions.loadPostsFailure, (state, { error }) => ({
      ...state, loading: false, error,
    })),

    on(PostsActions.deletePostSuccess, (state, { id }) => ({
      ...state,
      posts: state.posts.filter(p => p.id !== id),
      total: state.total - 1,
    })),
  ),
});

// createFeature automatically generates:
// postsFeature.reducer          — the reducer function
// postsFeature.selectPostsState — root feature selector
// postsFeature.selectPosts      — selects state.posts
// postsFeature.selectLoading    — selects state.loading
// etc.

// ── Register feature in app.config.ts ────────────────────────────────────
// provideState(postsFeature) inside providers array
// OR in app.routes.ts for lazy-loading: providers: [provideState(postsFeature)]
Note: createFeature() generates all basic selectors automatically from the state shape — if your state has a loading property, NgRx generates selectLoading. For derived state (e.g., publishedPosts filtered from posts), you compose additional selectors with createSelector(). The generated selectors are memoised — they only recompute when their input state changes, making reads from the store cheap even when the store updates frequently from other features.
Tip: Use createActionGroup() instead of individual createAction() calls for related actions. It groups request/success/failure triples cleanly, auto-generates action creators with camelCase names from the event strings ('Load Posts' becomes PostsActions.loadPosts), and ensures consistent naming across the feature. The source: 'Posts' prefix appears in Redux DevTools as [Posts] Load Posts, making the action log easy to read.
Warning: NgRx adds significant boilerplate — actions, reducers, effects, selectors, and feature registration for every domain. For a simple application (10–20 components, straightforward CRUD), service-based state with Signals is almost always the better choice. NgRx makes sense when the application has complex state interactions between many features, requires time-travel debugging (Redux DevTools), needs strict action traceability (audit logging), or has a large team where state mutation discipline matters. Do not add NgRx to a small application “just in case.”

When NgRx vs Service State

Criterion Service + Signals NgRx Store
App size Small–medium Medium–large
Team size 1–5 developers 5+ developers
State complexity Independent features Cross-feature state interactions
Debugging needs Console logs, breakpoints Redux DevTools, time-travel
Boilerplate Minimal High (actions, reducers, effects, selectors)
Testing Service unit tests Action/reducer/selector/effect unit tests

Common Mistakes

Mistake 1 — Mutating state in reducers (breaks immutability, causes missed updates)

❌ Wrong — state.posts.push(newPost); mutations skip change detection; selectors don’t recompute.

✅ Correct — always return new objects: { ...state, posts: [...state.posts, newPost] }.

Mistake 2 — Dispatching actions from reducers (side effects in reducers)

❌ Wrong — dispatching another action inside a reducer; reducers must be pure functions with no side effects.

✅ Correct — put side effects (HTTP calls, navigation) in NgRx Effects.

🧠 Test Yourself

A reducer handles deletePostSuccess by returning { ...state, posts: state.posts.filter(p => p.id !== id) }. Why does it use spread and filter instead of state.posts.splice()?