Frontend Engineering

NgRx Without Fear: Understanding Angular State and the Reducer Pattern

Afzal AhmedFaz Ahmed
·27 July 2026·25 min read
AngularNgRxReduxRxJSState ManagementReducersEffectsSelectorsNgRx EntityTypeScript

Why This Matters

My effort to make Angular state management understandable by tracing actions, reducers, selectors, effects, immutability, RxJS and when a store is actually justified.

Let’s decode this properly, because I know NgRx can look intimidating when you first meet it.

As Angular applications grow, the difficult part is often no longer components, templates or services. The difficult part is state. An application can reach the point where we lose track of what it knows, who changed it and why different parts of the UI no longer agree. NgRx addresses that problem by giving important state a known home and making changes flow in one direction.

That is the heart of the subject.

You might be thinking:

“NgRx is complicated.”

I would reframe it like this:

“NgRx is a disciplined way of controlling state change in a large UI.”

That distinction matters. NgRx is not there to make a small screen harder. It is there to stop a medium or enterprise Angular application turning into a messy spider web of services, subscriptions, duplicated data, random API calls, and components quietly changing each other’s data behind your back.

You can absolutely build Angular applications without NgRx. It is useful in certain situations, not mandatory everywhere. I would never install it merely because it sounds architectural; I use it when shared state, asynchronous flows and debugging complexity justify the pattern.

So let’s treat this like a real one-to-one tuition session.

Imagine we are building a Loan Management Supermarket Angular application.

The system has:

Applicants. Loan applications. Brokers. Underwriters. Documents. Lenders. Offers. Approval workflows. Filters. Dashboards. Notifications. Authentication. Admin settings.

At first, the Angular app is small. A LoanListComponent calls a LoanService, gets loans from the API, and displays them. Nice and simple.

// loan.service.ts
@Injectable({ providedIn: 'root' })
export class LoanService {
  constructor(private http: HttpClient) {}

  getLoans(): Observable<Loan[]> {
    return this.http.get<Loan[]>('/api/loans');
  }
}
// loan-list.component.ts
@Component({
  selector: 'app-loan-list',
  template: `
    <h2>Loans</h2>

    <div *ngIf="loading">Loading loans...</div>

    <ul>
      <li *ngFor="let loan of loans" (click)="selectLoan(loan)">
        {{ loan.applicantName }} - {{ loan.status }} - {{ loan.amount }}
      </li>
    </ul>
  `
})
export class LoanListComponent implements OnInit {
  loans: Loan[] = [];
  loading = false;

  constructor(private loanService: LoanService) {}

  ngOnInit(): void {
    this.loading = true;

    this.loanService.getLoans().subscribe({
      next: loans => {
        this.loans = loans;
        this.loading = false;
      },
      error: () => {
        this.loading = false;
      }
    });
  }

  selectLoan(loan: Loan): void {
    console.log('Selected loan', loan);
  }
}

For a small screen, this is perfectly fine.

Please notice that: perfectly fine.

You do not need NgRx for every dropdown, modal, tab selection, or small CRUD page. Some state belongs inside the component. Some state belongs in a simple service. Some state belongs in the URL. Some state belongs in NgRx.

The real skill is learning that difference.

The first mental model: what is state?

State means: what the application currently knows.

In our loan application, state can include:

export interface LoanState {
  loans: Loan[];
  selectedLoanId: string | null;
  loading: boolean;
  error: string | null;
  filters: LoanFilters;
}

That is not just data. That is the screen’s memory.

It knows whether loans are loading. It knows which loans are loaded. It knows which loan is selected. It knows whether an error happened. It knows which filters are active.

Now imagine this state is needed by five components:

LoanListComponent shows the list. LoanSummaryCardsComponent shows total approved/rejected/pending. LoanDetailsComponent shows selected loan details. UnderwriterPanelComponent shows actions for the selected loan. DocumentPanelComponent shows documents for the selected loan.

Without a state pattern, what usually happens?

The list component loads loans. The summary component loads loans again. The details component asks another service for selected loan. The document component stores its own selected loan ID. A filter component emits events upward. A parent component passes data downward. Then someone adds a refresh button. Then someone adds a dashboard. Then someone adds role-based behaviour. Then debugging becomes unpleasant.

This is where Redux thinking helps.

The problem Redux is solving

Flux grew from the pain of large interfaces becoming fragile and unpredictable when data moved in too many directions. When many views and models talk to each other differently, cascading effects become difficult to follow. Flux answers that with unidirectional data flow.

In plain English:

Old messy flow:

Component A changes service data
Service notifies Component B
Component B changes another service
Component C has old data
Component D makes another API call
Nobody knows who changed what

Redux/NgRx flow:

Component dispatches Action
Reducer calculates new State
Store holds State
Component selects State
Effects handle API calls

That is the whole subject.

Let’s make it visual in words:

User clicks button
      ↓
Angular Component dispatches an Action
      ↓
Reducer receives old State + Action
      ↓
Reducer returns new State
      ↓
Store saves the new State
      ↓
Selectors read the State
      ↓
Component UI updates

If API work is needed:

User clicks Load Loans
      ↓
Component dispatches loadLoans
      ↓
Effect hears loadLoans
      ↓
Effect calls Loan API
      ↓
Effect dispatches loadLoansSuccess or loadLoansFailure
      ↓
Reducer updates State
      ↓
Component updates from Store

That is NgRx.

Flux, Redux and NgRx: how they connect

Let me explain it as if we were sitting together at the same screen.

Flux is the architectural idea: actions, a dispatcher, a store, a view and one-way flow. An action combines intent with data, while the store is the central place for state and its management.

Redux simplifies and formalises this. Redux says:

There is one store. State is read-only. State changes happen through pure reducer functions. Actions describe what happened. Reducers calculate the next state. The UI reacts to state.

NgRx brings that Redux-style pattern into Angular and builds it with RxJS, Angular dependency injection, effects, selectors, entities, router-store, Store DevTools and feature modules.

So when you hear Redux, think pattern.

When you hear NgRx, think Angular implementation of that pattern.

The three Redux principles

Redux rests on three core principles: a single source of truth, read-only state, and changing state with pure functions.

Let’s decode those.

Single source of truth means the important application state is in one known place.

Instead of every component keeping its own copy of loans, you have:

export interface AppState {
  loans: LoanState;
  auth: AuthState;
  documents: DocumentState;
}

Now the question “what does the UI know?” has a real answer.

Read-only state means components do not directly mutate state.

Bad:

// Bad style in state management
this.loans.push(newLoan);
this.selectedLoan.status = 'Approved';

Why bad?

Because if components mutate state directly, you cannot trace who changed it. You lose predictability.

Better:

this.store.dispatch(LoanActions.approveLoan({ loanId }));

Now the action says what happened.

Changing state with pure functions means reducers are predictable.

A pure function means: same input, same output, no side effects.

function add(a: number, b: number): number {
  return a + b;
}

This is pure.

This is not pure:

function approveLoan(loan: Loan): Loan {
  // Side effect: talking to API inside calculation
  http.post('/api/approve', loan).subscribe();

  loan.status = 'Approved'; // mutation

  return loan;
}

A reducer should not call HTTP. A reducer should not write local storage. A reducer should not navigate. A reducer should not mutate the old object. A reducer should calculate the next state.

Building our Loan Management NgRx feature

Let’s now build a proper feature.

We will create:

loans/
  loan.model.ts
  loan.actions.ts
  loan.reducer.ts
  loan.selectors.ts
  loan.effects.ts
  loan-api.service.ts
  loan-list.component.ts

Larger Redux-style applications are easier to navigate when they are organised by business feature, with the actions, state, reducer, selectors and effects kept together.

Step 1: Model

// loan.model.ts

export interface Loan {
  id: string;
  applicantName: string;
  amount: number;
  status: 'Draft' | 'Submitted' | 'UnderReview' | 'Approved' | 'Rejected';
  brokerName: string;
  submittedOn: string;
}

export interface LoanFilters {
  status: string | null;
  brokerName: string | null;
  searchText: string;
}

This is our domain shape on the frontend.

Keep your DTOs clear. Do not let every API response become any. If an Angular application is full of any, TypeScript cannot give the team the protection it was chosen for.

Step 2: State

// loan.state.ts

export interface LoanState {
  loans: Loan[];

  // Instead of storing the whole selected loan object,
  // we store the selected ID. This avoids duplicated state.
  selectedLoanId: string | null;

  loading: boolean;
  error: string | null;

  filters: LoanFilters;
}

export const initialLoanState: LoanState = {
  loans: [],
  selectedLoanId: null,
  loading: false,
  error: null,
  filters: {
    status: null,
    brokerName: null,
    searchText: ''
  }
};

Now stop here.

This is one of the most important parts of NgRx: design the state first.

Many developers jump into actions and effects too quickly. Don’t. First ask:

What does the screen need to remember? What data comes from the server? What data is selected locally? What is loading? What can fail? What filters affect the list? What should survive navigation? What can be lost when the component is destroyed?

Not all state needs Redux. Some state can stay local to a component, while important shared state may belong in the store.

In our case, loans and selected loan matter across multiple components, so NgRx is reasonable.

Step 3: Actions — capture intent

Think of an action as intent plus payload. The intent is the verb—add, remove or select—and the payload is the data needed to carry it out.

In NgRx, actions are the language of the UI.

// loan.actions.ts

export const loadLoans = createAction(
  '[Loans Page] Load Loans'
);

export const loadLoansSuccess = createAction(
  '[Loans API] Load Loans Success',
  props<{ loans: Loan[] }>()
);

export const loadLoansFailure = createAction(
  '[Loans API] Load Loans Failure',
  props<{ error: string }>()
);

export const selectLoan = createAction(
  '[Loans Page] Select Loan',
  props<{ loanId: string }>()
);

export const updateFilters = createAction(
  '[Loans Filter] Update Filters',
  props<{ filters: Partial<LoanFilters> }>()
);

export const approveLoan = createAction(
  '[Loan Details Page] Approve Loan',
  props<{ loanId: string }>()
);

export const approveLoanSuccess = createAction(
  '[Loans API] Approve Loan Success',
  props<{ loan: Loan }>()
);

export const approveLoanFailure = createAction(
  '[Loans API] Approve Loan Failure',
  props<{ error: string }>()
);

Look at the naming.

[Loans Page] Load Loans tells us the action came from the page. [Loans API] Load Loans Success tells us the API call succeeded. [Loan Details Page] Approve Loan tells us the user intended to approve a loan.

This is not just syntax. This is traceability.

When a bug happens, you open Redux DevTools and see:

[Loans Page] Load Loans
[Loans API] Load Loans Success
[Loans Filter] Update Filters
[Loan Details Page] Approve Loan
[Loans API] Approve Loan Success

You can understand the story of the UI.

That is why Redux-style state management is powerful.

Step 4: Reducer — calculate the next state

A reducer receives the current state and an action, then returns the next state. A larger store delegates different slices of state to different reducer functions.

Our reducer:

// loan.reducer.ts

export const loanFeatureKey = 'loans';

export const loanReducer = createReducer(
  initialLoanState,

  on(LoanActions.loadLoans, state => ({
    ...state,

    // We do not mutate the old state.
    // We return a new state object with loading changed.
    loading: true,
    error: null
  })),

  on(LoanActions.loadLoansSuccess, (state, { loans }) => ({
    ...state,
    loans,
    loading: false,
    error: null
  })),

  on(LoanActions.loadLoansFailure, (state, { error }) => ({
    ...state,
    loading: false,
    error
  })),

  on(LoanActions.selectLoan, (state, { loanId }) => ({
    ...state,
    selectedLoanId: loanId
  })),

  on(LoanActions.updateFilters, (state, { filters }) => ({
    ...state,

    // We also copy the nested filters object.
    // If we only copied state but mutated filters,
    // we would still be mutating nested state.
    filters: {
      ...state.filters,
      ...filters
    }
  })),

  on(LoanActions.approveLoanSuccess, (state, { loan }) => ({
    ...state,

    // Replace only the updated loan.
    // Do not mutate the existing array item.
    loans: state.loans.map(existing =>
      existing.id === loan.id ? loan : existing
    )
  })),

  on(LoanActions.approveLoanFailure, (state, { error }) => ({
    ...state,
    error
  }))
);

Now let me review this with you as I would during a pull request.

This is good:

loans: state.loans.map(existing =>
  existing.id === loan.id ? loan : existing
)

This is bad:

const existing = state.loans.find(x => x.id === loan.id);
existing.status = loan.status;
return state;

Why bad?

Because it mutates existing state. Redux relies on immutability: create the next state instead of quietly changing the old one. This makes behaviour more predictable and supports simpler change detection.

In Angular, immutability helps the UI know something changed because object references change.

Think of it like this:

Mutation:
Same object, inside value changed quietly.

Immutability:
New object, clear evidence that state changed.

That is why NgRx works so well with Angular change detection.

Step 5: Selectors — read state properly

A common beginner mistake is injecting the store and manually digging through the full state everywhere.

Bad:

this.store.select(state => state.loans.loans);
this.store.select(state => state.loans.loading);
this.store.select(state => state.loans.selectedLoanId);

This scatters knowledge of state shape everywhere.

Selectors centralise it.

// loan.selectors.ts

export const selectLoanState =
  createFeatureSelector<LoanState>(loanFeatureKey);

export const selectAllLoans = createSelector(
  selectLoanState,
  state => state.loans
);

export const selectLoanLoading = createSelector(
  selectLoanState,
  state => state.loading
);

export const selectLoanError = createSelector(
  selectLoanState,
  state => state.error
);

export const selectLoanFilters = createSelector(
  selectLoanState,
  state => state.filters
);

export const selectSelectedLoanId = createSelector(
  selectLoanState,
  state => state.selectedLoanId
);

export const selectSelectedLoan = createSelector(
  selectAllLoans,
  selectSelectedLoanId,
  (loans, selectedLoanId) =>
    loans.find(loan => loan.id === selectedLoanId) ?? null
);

Now for filtered loans:

export const selectFilteredLoans = createSelector(
  selectAllLoans,
  selectLoanFilters,
  (loans, filters) => {
    return loans.filter(loan => {
      const matchesStatus =
        !filters.status || loan.status === filters.status;

      const matchesBroker =
        !filters.brokerName || loan.brokerName === filters.brokerName;

      const matchesSearch =
        !filters.searchText ||
        loan.applicantName
          .toLowerCase()
          .includes(filters.searchText.toLowerCase());

      return matchesStatus && matchesBroker && matchesSearch;
    });
  }
);

Selectors answer one question:

“What does this component need from the store?”

They are like SQL views over frontend state.

The raw store may have lots of data, but the component should receive the prepared view of data it needs.

My rule of thumb:

Components should not perform complex state calculations if selectors can do it cleanly.

Step 6: Component — dispatch actions and select view model

Now the component becomes simpler.

// loan-list.component.ts

@Component({
  selector: 'app-loan-list',
  template: `
    <section *ngIf="vm$ | async as vm">
      <h2>Loan Applications</h2>

      <div class="filters">
        <input
          placeholder="Search applicant..."
          [value]="vm.filters.searchText"
          (input)="onSearch($any($event.target).value)" />

        <select
          [value]="vm.filters.status ?? ''"
          (change)="onStatusChange($any($event.target).value)">
          <option value="">All statuses</option>
          <option value="Submitted">Submitted</option>
          <option value="UnderReview">Under Review</option>
          <option value="Approved">Approved</option>
          <option value="Rejected">Rejected</option>
        </select>
      </div>

      <div *ngIf="vm.loading">
        Loading loans...
      </div>

      <div *ngIf="vm.error">
        {{ vm.error }}
      </div>

      <ul>
        <li
          *ngFor="let loan of vm.loans"
          [class.selected]="loan.id === vm.selectedLoan?.id"
          (click)="selectLoan(loan.id)">

          <strong>{{ loan.applicantName }}</strong>
          <span>{{ loan.status }}</span>
          <span>{{ loan.amount | currency }}</span>
        </li>
      </ul>
    </section>
  `
})
export class LoanListComponent implements OnInit {
  vm$ = combineLatest({
    loans: this.store.select(LoanSelectors.selectFilteredLoans),
    selectedLoan: this.store.select(LoanSelectors.selectSelectedLoan),
    loading: this.store.select(LoanSelectors.selectLoanLoading),
    error: this.store.select(LoanSelectors.selectLoanError),
    filters: this.store.select(LoanSelectors.selectLoanFilters)
  });

  constructor(private store: Store) {}

  ngOnInit(): void {
    this.store.dispatch(LoanActions.loadLoans());
  }

  selectLoan(loanId: string): void {
    this.store.dispatch(LoanActions.selectLoan({ loanId }));
  }

  onSearch(searchText: string): void {
    this.store.dispatch(
      LoanActions.updateFilters({ filters: { searchText } })
    );
  }

  onStatusChange(status: string): void {
    this.store.dispatch(
      LoanActions.updateFilters({
        filters: { status: status || null }
      })
    );
  }
}

Now look at the component carefully.

It does not call the API directly. It does not mutate state directly. It does not know how approval works. It does not manually coordinate five services. It dispatches intent and reads state.

That is the NgRx mindset.

Component says:

“User wants to load loans.” “User selected this loan.” “User changed filters.”

Reducer says:

“Here is the new state.”

Effect says:

“I will handle the async API work.”

Selector says:

“Here is the exact data the component needs.”

Step 7: Effects — side effects live outside reducers

Side effects do not belong in the synchronous calculation performed by reducers. Accessing a file or calling an API involves the outside world, so NgRx gives us @ngrx/effects: injectable services that listen for actions, perform work and dispatch the result.

This is crucial.

Reducers must be pure.

API calls are not pure.

So API calls go into effects.

// loan.effects.ts

@Injectable()
export class LoanEffects {
  loadLoans$ = createEffect(() =>
    this.actions$.pipe(
      ofType(LoanActions.loadLoans),

      // switchMap is usually good for loading lists where a newer request
      // should replace an older request.
      switchMap(() =>
        this.loanApi.getLoans().pipe(
          map(loans =>
            LoanActions.loadLoansSuccess({ loans })
          ),

          catchError(error =>
            of(
              LoanActions.loadLoansFailure({
                error: 'Could not load loans.'
              })
            )
          )
        )
      )
    )
  );

  approveLoan$ = createEffect(() =>
    this.actions$.pipe(
      ofType(LoanActions.approveLoan),

      // exhaustMap is useful when you do not want repeated clicks
      // to create multiple approval requests at the same time.
      exhaustMap(({ loanId }) =>
        this.loanApi.approveLoan(loanId).pipe(
          map(loan =>
            LoanActions.approveLoanSuccess({ loan })
          ),

          catchError(error =>
            of(
              LoanActions.approveLoanFailure({
                error: 'Could not approve loan.'
              })
            )
          )
        )
      )
    )
  );

  constructor(
    private actions$: Actions,
    private loanApi: LoanApiService
  ) {}
}

API service:

// loan-api.service.ts

@Injectable({ providedIn: 'root' })
export class LoanApiService {
  constructor(private http: HttpClient) {}

  getLoans(): Observable<Loan[]> {
    return this.http.get<Loan[]>('/api/loans');
  }

  approveLoan(loanId: string): Observable<Loan> {
    return this.http.post<Loan>(
      `/api/loans/${loanId}/approve`,
      {}
    );
  }
}

Now let’s decode the effect.

ofType(LoanActions.loadLoans)

This means: “Only react when the loadLoans action is dispatched.”

switchMap(() => this.loanApi.getLoans())

This means: “Call the API, and if another load happens before the previous one completes, switch to the latest request.”

map(loans => LoanActions.loadLoansSuccess({ loans }))

This means: “When the API succeeds, dispatch a success action.”

catchError(error => of(LoanActions.loadLoansFailure(...)))

This means: “When the API fails, dispatch a failure action instead of breaking the stream.”

Now you understand effects.

They are the bridge between the synchronous Redux world and asynchronous real-world work.

Asynchronous work eventually produces loading, success or failure actions. Effects exist because HTTP interactions sit outside the reducer’s pure state calculation.

Step 8: Choosing the right RxJS operator in effects

This is where careful engineering judgement comes in.

NgRx is built on RxJS, so you need a working understanding of asynchronous streams, operators, error handling and testing. Values can arrive at any time, which means operator choice changes behaviour.

In NgRx effects, operator choice matters.

Use switchMap when the latest request wins.

Example: user types in a search box.

searchLoans$ = createEffect(() =>
  this.actions$.pipe(
    ofType(LoanActions.searchLoans),
    debounceTime(300),

    // If the user types again, cancel the previous search.
    switchMap(({ searchText }) =>
      this.loanApi.searchLoans(searchText).pipe(
        map(loans => LoanActions.searchLoansSuccess({ loans })),
        catchError(() =>
          of(LoanActions.searchLoansFailure({ error: 'Search failed.' }))
        )
      )
    )
  )
);

Use mergeMap when concurrent requests are allowed.

Example: upload multiple documents.

uploadDocument$ = createEffect(() =>
  this.actions$.pipe(
    ofType(DocumentActions.uploadDocument),

    // Multiple document uploads can happen at the same time.
    mergeMap(({ loanId, file }) =>
      this.documentApi.upload(loanId, file).pipe(
        map(document =>
          DocumentActions.uploadDocumentSuccess({ document })
        ),
        catchError(() =>
          of(DocumentActions.uploadDocumentFailure({ error: 'Upload failed.' }))
        )
      )
    )
  )
);

Use concatMap when order matters.

Example: save step 1, then step 2, then step 3.

saveWorkflowStep$ = createEffect(() =>
  this.actions$.pipe(
    ofType(WorkflowActions.saveStep),

    // Queue saves one after another.
    concatMap(({ step }) =>
      this.workflowApi.saveStep(step).pipe(
        map(savedStep =>
          WorkflowActions.saveStepSuccess({ step: savedStep })
        ),
        catchError(() =>
          of(WorkflowActions.saveStepFailure({ error: 'Save failed.' }))
        )
      )
    )
  )
);

Use exhaustMap when repeated clicks should be ignored until the first operation finishes.

Example: approve loan.

approveLoan$ = createEffect(() =>
  this.actions$.pipe(
    ofType(LoanActions.approveLoan),

    // Ignore repeated approval clicks until current approval finishes.
    exhaustMap(({ loanId }) =>
      this.loanApi.approveLoan(loanId).pipe(
        map(loan => LoanActions.approveLoanSuccess({ loan })),
        catchError(() =>
          of(LoanActions.approveLoanFailure({ error: 'Approval failed.' }))
        )
      )
    )
  )
);

This is interview-level knowledge.

You should be able to say:

“NgRx effects use RxJS streams. I choose switchMap for latest-wins flows, mergeMap for concurrent flows, concatMap for ordered flows, and exhaustMap to ignore repeated triggers while an operation is running.”

That answer tells me you understand the mechanics.

Step 9: Store DevTools — debugging the story of the UI

Redux DevTools help you see the state at a particular moment and trace which UI action caused it to change.

In real life, this is one of NgRx’s biggest benefits.

Without NgRx, someone says:

“The loan status changed randomly.”

You search components, services, subscriptions, outputs, subjects, route resolvers, API calls and local storage.

With NgRx, you inspect the action timeline:

[Loans Page] Load Loans
[Loans API] Load Loans Success
[Loan Details Page] Approve Loan
[Loans API] Approve Loan Success

Then you inspect state before and after each action.

That is not magic. That is architectural discipline.

That traceability is one of the reasons I value the pattern.

Step 10: Feature modules and slices of state

In a large enterprise Angular app, you do not want one giant state file.

You want slices.

export interface AppState {
  auth: AuthState;
  loans: LoanState;
  documents: DocumentState;
  brokers: BrokerState;
  notifications: NotificationState;
}

NgRx supports root state with forRoot() and independently owned feature slices with forFeature(), alongside effects, selectors, Entity and router-store.

Conceptually:

StoreModule.forRoot(...) registers root application state.

StoreModule.forFeature(...) registers feature state.

A feature module might look like:

@NgModule({
  imports: [
    CommonModule,

    StoreModule.forFeature(
      loanFeatureKey,
      loanReducer
    ),

    EffectsModule.forFeature([
      LoanEffects
    ])
  ],
  declarations: [
    LoanListComponent,
    LoanDetailsComponent,
    LoanFiltersComponent
  ]
})
export class LoansModule {}

This means the loan feature owns its state, reducer, effects and components.

That is how enterprise Angular stays organised.

Step 11: NgRx Entity — stop manually managing collections

Once your store contains lists, you often repeat the same logic:

Add item. Update item. Delete item. Find by ID. Store IDs. Store dictionary. Select all.

NgRx Entity exists to reduce this repeated collection boilerplate and provides consistent operations for selecting, creating, updating and deleting entities.

The idea is to store collections in a normalised structure.

Instead of this:

loans: [
  { id: '1', applicantName: 'Ali' },
  { id: '2', applicantName: 'Sarah' }
]

You store something conceptually like:

ids: ['1', '2'],
entities: {
  '1': { id: '1', applicantName: 'Ali' },
  '2': { id: '2', applicantName: 'Sarah' }
}

Why?

Because update by ID becomes fast and clean.

Example:

export interface LoanEntityState extends EntityState<Loan> {
  selectedLoanId: string | null;
  loading: boolean;
  error: string | null;
}

export const loanAdapter = createEntityAdapter<Loan>({
  selectId: loan => loan.id,
  sortComparer: (a, b) =>
    a.submittedOn.localeCompare(b.submittedOn)
});

export const initialState: LoanEntityState =
  loanAdapter.getInitialState({
    selectedLoanId: null,
    loading: false,
    error: null
  });

Reducer:

export const loanReducer = createReducer(
  initialState,

  on(LoanActions.loadLoans, state => ({
    ...state,
    loading: true,
    error: null
  })),

  on(LoanActions.loadLoansSuccess, (state, { loans }) =>
    loanAdapter.setAll(loans, {
      ...state,
      loading: false,
      error: null
    })
  ),

  on(LoanActions.approveLoanSuccess, (state, { loan }) =>
    loanAdapter.upsertOne(loan, state)
  ),

  on(LoanActions.deleteLoanSuccess, (state, { loanId }) =>
    loanAdapter.removeOne(loanId, state)
  )
);

Selectors:

const loanEntitySelectors =
  loanAdapter.getSelectors(selectLoanState);

export const selectAllLoans =
  loanEntitySelectors.selectAll;

export const selectLoanEntities =
  loanEntitySelectors.selectEntities;

export const selectSelectedLoan = createSelector(
  selectLoanEntities,
  selectSelectedLoanId,
  (entities, selectedId) =>
    selectedId ? entities[selectedId] ?? null : null
);

This is cleaner than manually writing array updates everywhere.

My rule of thumb:

If your feature state mainly manages collections by ID, consider NgRx Entity.

Step 12: Router state — URL is state too

@ngrx/router-store lets the application work with URLs, route parameters and query parameters as state, including custom serialization when only part of the router state is needed.

Why does this matter?

Because the URL often contains state:

/loans/123
/loans?status=Submitted&broker=John

Do not ignore route state when it already expresses the user’s selection.

If the selected loan is in the URL, then this:

/loans/123

means selected loan ID is 123.

That is better than storing selected ID only in component memory. Why? Because users can refresh the page, bookmark it, or send the link to someone else.

A route-based effect might be:

loadLoanFromRoute$ = createEffect(() =>
  this.actions$.pipe(
    ofType(routerNavigatedAction),

    map(action => action.payload.routerState.root.firstChild?.params['loanId']),

    filter((loanId): loanId is string => !!loanId),

    map(loanId =>
      LoanActions.selectLoan({ loanId })
    )
  )
);

This teaches a bigger lesson:

Not all state belongs in the NgRx store. Some state belongs in the route. Good architecture often combines both.

Step 13: Local state versus global state

This is where many developers overengineer.

Should a modal open/closed flag go in NgRx?

Usually no.

Should a text input’s current value go in NgRx?

Usually no, unless it must be shared, restored, synced or used by other features.

Should authenticated user go in NgRx?

Often yes.

Should loaded loan list go in NgRx?

Often yes, if multiple components depend on it.

Should selected tab inside one small component go in NgRx?

Probably not.

A practical guide:

Use component state when:
- state is temporary
- state belongs to one component
- state can be lost safely
- no other component needs it

Use service state when:
- state is shared between a small number of related components
- simple BehaviorSubject is enough
- you do not need action history/devtools/effects

Use NgRx when:
- state is shared across many areas
- state changes through many user actions
- async operations affect the state
- debugging who changed what matters
- caching and rehydration matter
- feature grows beyond simple service coordination

That is the judgement I want you to develop.

NgRx is not a medal. It is a tool.

Step 14: The full mental model in one flow

Let’s walk through one real action: approving a loan.

User clicks Approve.

approve(): void {
  this.store.dispatch(
    LoanActions.approveLoan({ loanId: this.loanId })
  );
}

Effect hears it:

approveLoan$ = createEffect(() =>
  this.actions$.pipe(
    ofType(LoanActions.approveLoan),
    exhaustMap(({ loanId }) =>
      this.loanApi.approveLoan(loanId).pipe(
        map(loan =>
          LoanActions.approveLoanSuccess({ loan })
        ),
        catchError(() =>
          of(
            LoanActions.approveLoanFailure({
              error: 'Approval failed.'
            })
          )
        )
      )
    )
  )
);

Reducer updates state:

on(LoanActions.approveLoanSuccess, (state, { loan }) => ({
  ...state,
  loans: state.loans.map(existing =>
    existing.id === loan.id ? loan : existing
  )
}))

Selector gives UI new data:

export const selectSelectedLoan = createSelector(
  selectAllLoans,
  selectSelectedLoanId,
  (loans, selectedId) =>
    loans.find(x => x.id === selectedId) ?? null
);

Component updates automatically:

<section *ngIf="selectedLoan$ | async as loan">
  <h2>{{ loan.applicantName }}</h2>
  <p>Status: {{ loan.status }}</p>
</section>

That is the pattern.

No component manually updates another component. No random service mutates shared data. No hidden side effect updates state silently. Everything flows through actions, effects, reducers, selectors and store.

Common mistakes to avoid

The first mistake is putting everything in NgRx. That makes small screens heavy.

The second mistake is putting API calls in reducers. Reducers must stay pure.

The third mistake is mutating state.

Bad:

state.loans.push(newLoan);
return state;

Good:

return {
  ...state,
  loans: [...state.loans, newLoan]
};

The fourth mistake is subscribing inside components and forgetting cleanup.

Bad:

ngOnInit(): void {
  this.store.select(selectAllLoans).subscribe(loans => {
    this.loans = loans;
  });
}

Better:

loans$ = this.store.select(selectAllLoans);

Template:

<li *ngFor="let loan of loans$ | async">
  {{ loan.applicantName }}
</li>

The fifth mistake is deriving data in components instead of selectors.

Bad:

this.approvedLoans = loans.filter(x => x.status === 'Approved');

Better:

export const selectApprovedLoans = createSelector(
  selectAllLoans,
  loans => loans.filter(x => x.status === 'Approved')
);

The sixth mistake is poor action names.

Bad:

updateData()
doStuff()
setThing()

Good:

[Loans Page] Load Loans
[Loans API] Load Loans Success
[Loan Details Page] Approve Loan

Actions should tell the story.

How to explain NgRx confidently

If someone asks you, “What is NgRx?” answer like this:

“NgRx is Redux-style state management for Angular. It gives the application a predictable state flow: components dispatch actions, reducers calculate new immutable state, selectors read slices of state, and effects handle side effects such as HTTP calls. It is useful when an Angular app has shared state, complex user interactions, asynchronous workflows, caching needs, and debugging requirements. I would not use it for every small component; I would use it where state complexity justifies the structure.”

If they ask, “Why not just use services?”

Say:

“For simple state, services are fine. But as the app grows, services can become informal stores with scattered BehaviorSubjects, duplicated state, hidden mutations and unclear update paths. NgRx formalises the flow. It gives us action history, reducer purity, immutable state, selectors, effects and predictable debugging.”

If they ask, “What is a reducer?”

Say:

“A reducer is a pure function that receives the current state and an action, then returns the next state. It does not call APIs, mutate existing state, navigate, log to external systems, or perform side effects.”

If they ask, “What are effects?”

Say:

“Effects listen for actions and handle side effects, usually HTTP calls. They transform an incoming action into a success or failure action. This keeps reducers pure and keeps asynchronous work outside state calculation.”

If they ask, “What are selectors?”

Say:

“Selectors are reusable queries over the store. They hide state structure from components and prepare view-specific data such as filtered lists, selected entities and calculated summary values.”

Mentoring checkpoint: current standalone registration

NgRx supports both module-based and standalone Angular applications. For a new standalone application, current NgRx guidance recommends registering the root Store infrastructure with provideStore() and feature state with provideState(). Effects can be registered with provideEffects().

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideStore, provideStoreDevtools } from '@ngrx/store';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideStore(),
    provideStoreDevtools({ maxAge: 50 }),
  ],
};

The lazy route can own the feature lifetime:

// loans.routes.ts
import { Routes } from '@angular/router';
import { provideEffects } from '@ngrx/effects';
import { provideState } from '@ngrx/store';
import { loansFeature } from './state/loans.feature';
import * as loansEffects from './state/loans.effects';

export const LOAN_ROUTES: Routes = [
  {
    path: '',
    providers: [
      provideState(loansFeature),
      provideEffects(loansEffects),
    ],
    loadComponent: () =>
      import('./loans-page.component').then(m => m.LoansPageComponent),
  },
];
Junior: Should every lazy route register its own Store feature?
>
Senior: Only when that state belongs to the feature and its lifecycle. Authentication identity or application-wide reference data may belong higher. Route registration is a technical option, not the definition of ownership.
Keep the project’s actual Angular and NgRx versions aligned and use their matching migration guidance. Do not paste current standalone syntax into an older module application halfway through a feature without understanding provider registration.

Mentoring build: an approval queue with real state pressure

Our original loan list demonstrates the loop. Now the product request becomes more realistic:

  • users page, sort and filter an approval queue;
  • selecting a row loads detail;
  • two underwriters may view the same application;
  • approval is optimistic but must handle version conflict;
  • navigating back should preserve URL-shareable filters;
  • a refresh should not show stale detail as if it were current;
  • failures need retry and useful diagnostics.
Before coding, classify the state:
StateOwnerShareable?Persisted?
Filter and pageURL/routerYesIn URL
Loaded loan entitiesNgRx featureIndirectlyMemory cache
Selected application IDURL/routerYesIn URL
Load/command statusNgRx featureNoNo
Edit form keystrokesComponent/formNoUsually no
Authenticated userAuth/application boundaryNoSession mechanism
Server versionEntity stateNoRefetched
Junior: Why not put every field in Store so DevTools can see it?
>
Senior: Because ownership matters more than visibility. A text field used by one form can stay local. Store state earns its place when several consumers coordinate around it, it participates in a workflow, or its history materially helps debugging.
Do not duplicate the same truth. If the selected ID is in the route, derive it from router state instead of maintaining another independently writable selectedLoanId unless there is a deliberate reason.

Shape state around entities and operations

A production feature needs more than loans and loading:

import { EntityState, createEntityAdapter } from '@ngrx/entity';

export interface Loan {
  id: string;
  applicantName: string;
  requestedAmount: number;
  currency: string;
  status: 'Submitted' | 'InReview' | 'Approved' | 'Declined';
  version: number;
  updatedAt: string;
}

export type RequestStatus =
  | { kind: 'idle' }
  | { kind: 'loading'; requestId: string }
  | { kind: 'succeeded'; receivedAt: number }
  | { kind: 'failed'; message: string; correlationId?: string };

export interface LoansState extends EntityState<Loan> {
  listStatus: RequestStatus;
  commandStatusById: Record<string, RequestStatus>;
  lastQueryKey: string | null;
}

export const loansAdapter = createEntityAdapter<Loan>({
  selectId: loan => loan.id,
  sortComparer: (a, b) => b.updatedAt.localeCompare(a.updatedAt),
});

export const initialState: LoansState = loansAdapter.getInitialState({
  listStatus: { kind: 'idle' },
  commandStatusById: {},
  lastQueryKey: null,
});

A discriminated union prevents contradictory loading: true and error: 'failed'. Per-entity command status allows one row to submit without disabling the entire queue. The server’s version supports optimistic concurrency.

Store only serialisable values. Use ISO strings or numbers for time rather than Date, avoid class instances, subscriptions, DOM nodes and service references. Serialisability supports DevTools, persistence and comprehensible action history.

Do not place the access token in Store. Credentials belong in the authentication mechanism and HTTP boundary, not state snapshots or developer tooling.

Name actions as events and commands deliberately

Page actions capture user or lifecycle intent; API actions capture outcomes.

import { createActionGroup, emptyProps, props } from '@ngrx/store';

export const LoansPageActions = createActionGroup({
  source: 'Loans Page',
  events: {
    Entered: emptyProps(),
    'Retry Clicked': emptyProps(),
    'Approve Clicked': props<{ loanId: string }>(),
  },
});

export const LoansApiActions = createActionGroup({
  source: 'Loans API',
  events: {
    'Load Started': props<{ requestId: string; queryKey: string }>(),
    'Load Succeeded': props<{
      requestId: string;
      queryKey: string;
      loans: Loan[];
      receivedAt: number;
    }>(),
    'Load Failed': props<{
      requestId: string;
      queryKey: string;
      error: UiError;
    }>(),
    'Approve Succeeded': props<{
      operationId: string;
      loan: Loan;
    }>(),
    'Approve Failed': props<{
      operationId: string;
      loanId: string;
      previous: Loan;
      error: UiError;
    }>(),
  },
});

An action is not an RPC call. Several reducers and effects may observe it. Keep payloads minimal, safe and meaningful. Do not include an HttpErrorResponse, service, callback or whole form object. Translate infrastructure errors into a serialisable UiError.

Junior: Why do we need Load Started if Entered already happened?
>
Senior: Entered is user/interface intent. The effect decides whether a fetch is needed and assigns a request identity. Separating them makes caching and stale-response handling explicit. A simpler feature may combine them; the design pressure decides.
Avoid action names such as SetLoadingTrue that expose reducer mechanics. “Approve succeeded” describes a fact and lets state evolve without changing the event vocabulary.

Write reducers as state-transition policy

import { createFeature, createReducer, on } from '@ngrx/store';

export const loansFeature = createFeature({
  name: 'loans',
  reducer: createReducer(
    initialState,
    on(LoansApiActions.loadStarted, (state, { requestId, queryKey }) => ({
      ...state,
      listStatus: { kind: 'loading', requestId },
      lastQueryKey: queryKey,
    })),
    on(LoansApiActions.loadSucceeded,
      (state, { requestId, queryKey, loans, receivedAt }) => {
        if (state.lastQueryKey !== queryKey ||
            state.listStatus.kind !== 'loading' ||
            state.listStatus.requestId !== requestId) {
          return state;
        }

        return loansAdapter.setAll(loans, {
          ...state,
          listStatus: { kind: 'succeeded', receivedAt },
        });
      }),
    on(LoansApiActions.loadFailed,
      (state, { requestId, error }) => {
        if (state.listStatus.kind !== 'loading' ||
            state.listStatus.requestId !== requestId) {
          return state;
        }
        return {
          ...state,
          listStatus: {
            kind: 'failed',
            message: error.message,
            correlationId: error.correlationId,
          },
        };
      }),
    on(LoansApiActions.approveSucceeded, (state, { loan, operationId }) =>
      loansAdapter.upsertOne(loan, {
        ...state,
        commandStatusById: {
          ...state.commandStatusById,
          [loan.id]: { kind: 'succeeded', receivedAt: Date.now() },
        },
      })),
  ),
});

There is one impurity hidden here: Date.now() inside the reducer. That violates determinism. Pass receivedAt in the action just as the load action does. A reducer should return the same state for the same input state and action.

Junior: The code compiles. Why does the clock matter?
>
Senior: Replay the same action and you get a different state. Tests need clock tricks, DevTools history is not deterministic, and the reducer is no longer a pure transition. Effects or event creators obtain time; reducers consume it.
Correct it:
'Approve Succeeded': props<{
  operationId: string;
  loan: Loan;
  receivedAt: number;
}>()

Then use receivedAt in the handler.

Never mutate nested records:

// Wrong: both the state and nested object retain/mutate identity.
state.commandStatusById[loanId] = { kind: 'loading', requestId };
return state;

// Correct: copy every changed level.
return {
  ...state,
  commandStatusById: {
    ...state.commandStatusById,
    [loanId]: { kind: 'loading', requestId },
  },
};

NgRx runtime checks in development can catch some mutation and serialisability mistakes. They complement, not replace, clear state ownership and reducer tests.

Selectors create the component contract

The component should not know Entity’s ids/entities shape or error storage.

import { createSelector } from '@ngrx/store';

const adapterSelectors = loansAdapter.getSelectors(loansFeature.selectLoansState);

export const selectRouteQuery = createSelector(
  selectRouterState,
  router => ({
    status: router.state.root.queryParams['status'] ?? 'all',
    page: Number(router.state.root.queryParams['page'] ?? 1),
  }),
);

export const selectLoansPageVm = createSelector(
  adapterSelectors.selectAll,
  loansFeature.selectListStatus,
  loansFeature.selectCommandStatusById,
  selectRouteQuery,
  (loans, listStatus, commands, query) => ({
    loans: query.status === 'all'
      ? loans
      : loans.filter(x => x.status === query.status),
    loading: listStatus.kind === 'loading',
    error: listStatus.kind === 'failed' ? listStatus : null,
    commandBusy: (id: string) => commands[id]?.kind === 'loading',
    query,
  }),
);

Returning a function from a selector can be convenient but deserves measurement and care because its identity changes when the projector reruns. An alternative is to expose the command map and use a pure component method, or create a selector factory for a specific row. Do not create selector factories repeatedly inside a template loop.

Selectors are memoised based on input references. Immutable updates let unchanged references signal unchanged input. If a projector returns a new sorted array on every unrelated state change, component rendering may increase. Keep expensive projections focused and profile before micro-optimising.

Do not put presentation-only strings and formatted currency in global state. Select raw domain values and format in a pipe or view-model selector that understands locale.

Present Store state through signals or observables intentionally

Current NgRx supports Store selection as signals as well as observables. A standalone component can use selectSignal:

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';

@Component({
  standalone: true,
  selector: 'app-loans-page',
  templateUrl: './loans-page.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoansPageComponent {
  private readonly store = inject(Store);
  readonly vm = this.store.selectSignal(selectLoansPageVm);

  approve(loanId: string): void {
    this.store.dispatch(LoansPageActions.approveClicked({ loanId }));
  }

  retry(): void {
    this.store.dispatch(LoansPageActions.retryClicked());
  }
}

Signals do not make RxJS obsolete. Effects and many Angular APIs remain stream-shaped; Store itself is reactive. Use the representation that fits the boundary, avoid converting back and forth without need, and understand subscription/lifecycle semantics.

The component dispatches intent and renders a view model. It does not subscribe just to copy Store values into local fields. Forms remain local until submit; their initial values can come from a selector.

Effects coordinate I/O and concurrency policy

NgRx documents that reducers process dispatched actions before effects observe them. Effects then handle asynchronous or impure work and emit outcome actions.

import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { catchError, concatMap, map, of, switchMap, withLatestFrom } from 'rxjs';

export const loadLoans = createEffect(
  (
    actions$ = inject(Actions),
    store = inject(Store),
    api = inject(LoansApi),
    clock = inject(AppClock),
    ids = inject(OperationIds),
  ) => actions$.pipe(
    ofType(LoansPageActions.entered, LoansPageActions.retryClicked),
    withLatestFrom(store.select(selectRouteQuery)),
    switchMap(([, query]) => {
      const requestId = ids.next();
      const queryKey = JSON.stringify(query);

      return api.getLoans(query).pipe(
        map(loans => LoansApiActions.loadSucceeded({
          requestId,
          queryKey,
          loans,
          receivedAt: clock.now(),
        })),
        catchError(error => of(LoansApiActions.loadFailed({
          requestId,
          queryKey,
          error: toUiError(error),
        }))),
      );
    }),
  ),
  { functional: true },
);

This example omitted dispatch of Load Started; either emit it from a preceding effect/action or simplify the action design. That deliberate observation is important: snippets should be reviewed as workflows, not admired in isolation.

A clearer effect can map page intent to a started action, and a second effect performs the request:

export const beginLoad = createEffect(
  (actions$ = inject(Actions), store = inject(Store), ids = inject(OperationIds)) =>
    actions$.pipe(
      ofType(LoansPageActions.entered, LoansPageActions.retryClicked),
      withLatestFrom(store.select(selectRouteQuery)),
      map(([, query]) => LoansApiActions.loadStarted({
        requestId: ids.next(),
        queryKey: JSON.stringify(query),
      })),
    ),
  { functional: true },
);

The request effect derives or stores the query associated with queryKey. Better still, put the validated query in the started action if it is small and serialisable, eliminating reconstruction ambiguity.

Choose flattening operators as product behaviour

  • switchMap cancels the prior subscription when a newer load arrives. Good for query/filter changes where only the latest result matters, provided cancellation is safe.
  • exhaustMap ignores new triggers while one is active. Useful for preventing repeated submission, but ignored clicks may confuse users.
  • concatMap queues operations and preserves order. Useful where every command must run, but a long queue increases latency.
  • mergeMap runs concurrently. Useful for independent work, but results can arrive out of order and overload dependencies.
Junior: Which operator is best for approving loans?
>
Senior: First define behaviour. Should a second click be ignored, queued or treated as a duplicate? Can two different rows approve concurrently? Operator choice follows that policy.
For the same row, block duplicate UI actions and use a stable operation ID. Across different rows, controlled concurrency may be valid. The server must still enforce idempotency and versioning; RxJS cannot protect against another browser or a retry after network ambiguity.

Keep catchError inside the inner stream. If it terminates the outer effect stream, the effect may stop responding to future actions.

Implement optimistic approval without lying to the user

Optimistic UI updates immediately, then confirms or rolls back. It is appropriate only when rollback is understandable and the server remains authoritative.

export const LoansCommandActions = createActionGroup({
  source: 'Loans Commands',
  events: {
    'Approval Optimistically Applied': props<{
      loanId: string;
      operationId: string;
      previous: Loan;
    }>(),
  },
});

The reducer changes the status to Approved and records loading. The effect sends loanId, expected version and operation ID. Success replaces the optimistic entity with the server representation. Failure restores previous only if the current pending operation still matches operationId.

That last condition prevents an old failure from rolling back a newer successful attempt:

on(LoansApiActions.approveFailed,
  (state, { loanId, operationId, previous, error }) => {
    const pending = state.commandStatusById[loanId];
    if (pending?.kind !== 'loading' || pending.requestId !== operationId) {
      return state;
    }

    return loansAdapter.upsertOne(previous, {
      ...state,
      commandStatusById: {
        ...state.commandStatusById,
        [loanId]: {
          kind: 'failed',
          message: error.message,
          correlationId: error.correlationId,
        },
      },
    });
  }),

On HTTP 409, do not merely restore and show “failed.” Fetch current server state and tell the user another update occurred. The user may need to review changed evidence before retrying.

Junior: Why not wait for the server and avoid all this complexity?
>
Senior: That may be the better choice for consequential approvals. Optimism is a user-experience trade-off, not a maturity badge. Use it when latency is material and reversal is safe and clear.

URL, Store and cache consistency

Filters, sort and page belong in query parameters when users should bookmark and share them. Dispatch navigation from a user interaction or navigate directly; let router selectors drive the query.

Avoid a loop:

route changes -> effect dispatches filter action
filter action -> effect navigates
navigation -> route changes forever

Choose one source of truth. If URL is authoritative, selectors read it and data-loading effects react to navigation/query changes. Component controls navigate instead of separately writing filter state.

Cache policy should be explicit. Define a query key, received time and freshness duration. An effect can skip reload when matching data is fresh, while a user refresh action bypasses it. Do not hide a five-minute cache in a selector; selectors must remain pure.

When the user leaves a lazy route, feature state may be removed depending on provider lifetime and configuration. Decide whether returning should refetch or preserve data at a higher owner. Test actual behaviour rather than assuming.

Error design and retry

Translate HttpErrorResponse into a small safe model:

export interface UiError {
  kind: 'validation' | 'forbidden' | 'conflict' | 'unavailable' | 'unexpected';
  message: string;
  correlationId?: string;
  retryable: boolean;
}

Do not store response bodies, headers or stack traces in Store. They may contain sensitive data and make DevTools unsafe. User messages should be stable; diagnostics use the server correlation ID.

Automatic retry belongs in the HTTP/integration policy or a focused effect and only for transient, safe operations. A GET may retry with bounded backoff. A write needs idempotency and an understood server contract. Validation, forbidden and conflict responses are not transient.

Model retry as a new user intent when human judgement is needed. Clear or replace stale errors on a new operation. Avoid a single feature-wide error that makes an approval failure appear as a list-loading failure.

Testing reducers as transition tables

Reducer tests need no Angular TestBed:

describe('loans reducer', () => {
  it('ignores a stale load success', () => {
    const state: LoansState = {
      ...initialState,
      lastQueryKey: 'status=Submitted',
      listStatus: { kind: 'loading', requestId: 'new-request' },
    };

    const action = LoansApiActions.loadSucceeded({
      requestId: 'old-request',
      queryKey: 'status=Submitted',
      loans: [loanFixture()],
      receivedAt: 100,
    });

    expect(loansFeature.reducer(state, action)).toBe(state);
  });
});

Test identity (toBe) when the reducer should ignore an action. Test that changed paths receive new references and unchanged parts retain theirs. Freeze fixtures in development tests if mutation is a recurring risk.

A transition table prevents gaps:

CurrentActionExpected
idleload startedloading with request ID
loading same IDsuccessentities replaced, succeeded
loading newer IDold successunchanged
loading same IDfailurefailed with safe error
approval pending Afailure Aprevious entity restored
approval pending Bfailure Aunchanged
Selector tests call projector logic with fixtures. Verify filters, sort, missing entity and loading/error combinations. Do not test NgRx’s own Entity adapter implementation; test the feature rules around it.

Testing effects and components

Functional effects with injected arguments can be tested with controlled action streams and fakes. Assert output actions and cancellation/concurrency behaviour, not internal operator arrangement.

For time-dependent streams, RxJS TestScheduler or carefully controlled subjects can make virtual time explicit. Marble tests are powerful when timing is the behaviour; they can be cryptic for simple one-request effects. Choose the clearest proof.

Test that an effect survives an error by emitting a second trigger after the failure. Test a rapid query change so the old response cannot replace the new one. Test operation IDs, expected versions and safe error mapping.

Component tests should interact with the DOM and assert dispatched intent or rendered view state. provideMockStore can override selectors for focused presentation tests. At least one integration test should use the real reducer/effect/API boundary so compatible mocks do not hide wiring mistakes.

End-to-end tests cover routing, refresh, back/forward navigation, conflict response and retry. They are slower, so use them for critical journeys rather than every reducer branch.

Debugging clinic: the old search result flashes back

The user changes filter from Submitted to Approved. The approved response returns first; then the slow submitted response arrives and overwrites the list.

Inspect DevTools action order and payloads:

Load Started request=A query=Submitted
Load Started request=B query=Approved
Load Succeeded request=B query=Approved
Load Succeeded request=A query=Submitted

If switchMap is wired to the query trigger and the HTTP client honours unsubscription, A may be cancelled. Still protect the reducer with request/query identity because a non-cancellable source, cache or future refactor may deliver it.

Do not fix this by adding arbitrary delays or clearing the array. The bug is an ownership rule: only the result matching current request and query may update current-list status. Entity caching across queries may require a separate result-ID list keyed by query rather than setAll, depending on product needs.

Junior: DevTools shows the right actions. Why did the UI still render old data?
>
Senior: Follow selector inputs and component identity. The reducer may have accepted a stale action, the selector may read all cached entities rather than current result IDs, or the template may reuse rows with an incorrect tracking key.

Debugging clinic: an effect silently stops

The list loads once, an API request fails, and later retries do nothing. A common cause is catchError outside the inner request stream:

actions$.pipe(
  ofType(LoansPageActions.entered),
  switchMap(() => api.getLoans()),
  map(loans => LoansApiActions.loadSucceeded(/* ... */)),
  catchError(error => of(LoansApiActions.loadFailed(/* ... */))),
);

After the outer stream catches and completes, it no longer listens to actions. Put error handling inside the switchMap projection so each request becomes its own success/failure stream while the action listener survives.

Add a regression test that triggers failure then success. Monitor effect-driven outcome gaps: a rising number of load-started actions without success/failure indicates a broken or stuck path.

Debugging clinic: duplicate API calls after navigation

Duplicate calls may come from feature providers registered twice, both Entered and router-navigation effects triggering load, multiple components dispatching the same lifecycle action, or imperative subscriptions created repeatedly.

Use action and network traces to count triggers. Inspect provider boundaries. Functional effects registered at both root and route can each respond. Do not mask the issue with distinctUntilChanged until you know why duplicate intent exists.

Choose one owner for page entry. If caching intentionally coalesces duplicates, implement it with query and in-flight state and test it. A server idempotency key is still required for writes.

Store DevTools and privacy

Action and state history is powerful. It can also expose applicant names, amounts, error payloads or tokens to anyone with tooling access. Minimise state and action payloads, disable or restrict production instrumentation according to policy, and sanitise where appropriate.

Time-travel debugging replays reducers, not external effects that already happened. It does not undo an approval on the server. This is another reason reducers must remain pure and business writes must have their own audit and idempotency.

When diagnosing production, structured application telemetry should record action category, operation ID, request ID, route/query key, duration, outcome and server correlation ID without copying whole state. NgRx action streams can be observed carefully, but logging every action payload indiscriminately is unsafe and noisy.

Performance and change detection

NgRx is not automatically fast or slow. State shape, selector work, component boundaries and template rendering matter.

Normalise large collections. Update only changed entities. Use stable IDs in template tracking. Avoid selecting the entire feature when a component needs one boolean. Do not perform expensive filtering in a template method that runs repeatedly.

Memoised selectors save projector work only when input references remain stable. A reducer that clones a large slice for unrelated actions invalidates memoisation. Conversely, returning the same mutated object breaks change detection and correctness. Immutable, minimal updates are both semantic and performance tools.

Measure with Angular profiling, browser performance tools and selector instrumentation where needed. Do not add custom memoisation or denormalised copies before demonstrating a bottleneck. Duplication increases consistency risk.

Choosing between Store, Signal Store, Component Store and a service

The choice is about coordination and lifecycle:

  • Component signal/field: local presentation or form state.
  • Service with signals/observables: focused shared state with a small mutation surface.
  • Component Store or local NgRx state: complex feature/component workflows that benefit from updater/effect structure without global events.
  • NgRx Store: application/feature state shared across routes and workflows where actions, reducers, selectors, effects and DevTools provide material value.
  • NgRx Signal Store: a signal-oriented store option with its own composition model; evaluate it against team conventions and problem needs.
Current NgRx guidance itself points temporary/local component state toward signal-oriented options. That does not make classic Store obsolete. A regulated approval workflow shared across routes may benefit from event history and reducer discipline; an accordion’s open panel does not.
Junior: Should one application use only one state style?
>
Senior: Consistency is valuable, but forcing every lifetime into global Store is false consistency. Define team guidance for each category and keep transitions between them explicit.
Avoid keeping a service BehaviorSubject and NgRx Store as competing sources of the same loans. If migrating, establish one direction, move consumers and remove the old source.

Production deployment and operations

Frontend and API versions overlap during deployment and browser caching. Make contracts additive where possible. A newly required response field can break older clients; an enum value added by the server can break an exhaustive UI mapping. Handle unknown values safely and deploy compatible stages.

Do not persist the entire NgRx Store to browser storage by default. State may be sensitive, stale or incompatible after release. Persist only approved slices with schema version, expiry and migration. Authentication tokens need dedicated secure design, not a meta-reducer convenience.

Feature flags should not create impossible state when toggled. If an approval feature turns off while an operation is pending, decide whether it completes, cancels or becomes read-only. Remove expired flags and their reducer branches.

Useful operational signals include:

  • load and command outcome rates;
  • p50/p95 API duration by operation;
  • conflict, forbidden and retry rates;
  • stale-response actions ignored;
  • optimistic rollback rate;
  • client error and effect failure categories;
  • feature version and server correlation IDs;
  • user-visible time to usable queue.
Frontend monitoring must respect privacy. A session replay or state capture can record financial data; apply consent, redaction, access and retention controls.

Code-review checklist for an NgRx feature

I would ask:

  • Is each state value owned once, with URL/local/global boundaries clear?
  • Are actions meaningful, minimal, safe and serialisable?
  • Are reducers pure, immutable and deterministic?
  • Can stale async results overwrite current state?
  • Does the flattening operator match the product concurrency policy?
  • Is error handling inside the inner effect stream?
  • Do writes carry server idempotency and concurrency information?
  • Are selectors the component contract rather than exposed state structure?
  • Are subscriptions lifecycle-safe, or avoided through signals/async pipe?
  • Do tests cover transitions, races, effect survival and wiring?
  • Can DevTools or telemetry expose sensitive data?
  • Are lazy providers registered exactly once at the intended lifetime?
  • Is the complexity justified compared with a local signal or service?
The review should trace one action end to end. Start at the user event, inspect payload, follow effects, API contract and reducer, then see which selector updates the DOM. If the trace requires guessing, rename or reorganise until the story is visible.

Exercises for the developer I am mentoring

Exercise one: classify state

Take a filterable detail page and label every value as URL, component, form, service cache, NgRx feature or server state. Remove duplicates. Explain which values survive refresh and why.

Exercise two: write a transition table

Model loading with request IDs and approval with operation IDs. Write reducer tests for stale load success and stale rollback before writing effects. Ensure ignored actions return the same state reference.

Exercise three: compare RxJS operators

Use a fake delayed API and trigger rapid query changes with switchMap, concatMap, mergeMap and exhaustMap. Record requests started, cancelled, ignored and completed. Explain which behaviour the page requires.

Exercise four: survive failure

Make the first API call fail and the second succeed. Prove the effect remains subscribed. Add a timeout and translate it into a safe serialisable error with correlation ID.

Exercise five: create a concurrency conflict

Load version seven in two browser fixtures. Approve from one, then submit the stale second command. Return 409, refresh server state and show a clear user message. Do not silently retry the stale decision.

Exercise six: audit sensitive state

Inspect Store, actions, DevTools, browser storage and telemetry. Seed a fake sensitive value and prove it does not appear where policy forbids it. Add a regression check or sanitizer.

Cross-links for the next lesson

Use Angular Modern Web Development for standalone components, routing, signals and application architecture. Continue with C# Async/Await, Race Conditions and Locks to compare asynchronous race reasoning across browser and server. The HTTP and Web APIs from First Principles guide strengthens caching, idempotency, concurrency and error contracts. Web Security for Full-Stack Developers develops token and browser-storage threats, while How to Investigate Slow Angular, ASP.NET Core and SQL Server Applications follows one request across the full stack.

Together they teach the important boundary: NgRx can make browser state transitions predictable, but server correctness, access control, durable consistency and observability still require end-to-end design.

Advanced state shape: cached entities versus query results

EntityState answers which loan objects are known. It does not answer which IDs belong to the current server query, whether the result is complete or when that query was fetched. If filtering is server-side, selecting all cached entities can mix results from different pages.

A more precise state separates the entity cache from query metadata:

export interface LoanQueryResult {
  ids: string[];
  total: number;
  receivedAt: number;
  status: RequestStatus;
}

export interface LoansState extends EntityState<Loan> {
  queries: Record<string, LoanQueryResult>;
  activeQueryKey: string | null;
  commandStatusById: Record<string, RequestStatus>;
}

On success, upsertMany adds or updates entities while the query entry stores ordered result IDs. A selector maps those IDs to entities:

export const selectActiveQueryResult = createSelector(
  loansFeature.selectQueries,
  loansFeature.selectActiveQueryKey,
  (queries, key) => key ? queries[key] : undefined,
);

export const selectVisibleLoans = createSelector(
  adapterSelectors.selectEntities,
  selectActiveQueryResult,
  (entities, result) =>
    result?.ids.flatMap(id => entities[id] ? [entities[id]!] : []) ?? [],
);

This avoids treating a partial server page as the entire collection. It also makes cache freshness query-specific. The price is invalidation complexity.

Junior: After approving one loan, which cached queries should change?
>
Senior: That is the hard question. If status changes from Submitted to Approved, it may leave one result and enter another. We can update known results carefully, mark affected query families stale, or refetch active data. Choose the simplest policy that remains correct.
For a first release, update the entity from the server response and invalidate cached query results whose membership may have changed. Refetch the active query. This costs a request but reduces fragile client-side reconstruction of server filters, permissions and sort rules.

Cache eviction also matters. Query keys containing every search term can grow without limit during a long session. Bound the number of entries, expire them, or clear feature cache when its owner ends. Do not let Store become an accidental browser database.

Feature teardown and late emissions

A lazy feature can be left while an HTTP call is running. Depending on routing, providers and effect lifetime, cancellation may occur or a late action may reach a re-entered feature. Request identity and query validation remain valuable.

If the feature must explicitly cancel work, dispatch a lifecycle action and use takeUntil inside the request stream:

const featureLeft$ = actions$.pipe(ofType(LoansPageActions.left));

return api.getLoans(query).pipe(
  takeUntil(featureLeft$),
  map(/* success */),
  catchError(/* failure */),
);

Do not emit a normal failure toast for deliberate navigation cancellation. Model cancellation separately or let the stream end without an outcome while feature teardown removes loading state. If state persists above the route, a cancellation action may need to reset pending status explicitly.

Dispatching Entered and Left from component lifecycle can be fragile with reused routes or nested components. Router Store actions or route-level ownership may be more reliable. Test the project’s actual navigation and reuse behaviour.

Detached imperative subscriptions are another source of late writes. Prefer effects, async pipe, Store signals and Angular lifecycle utilities. When an imperative subscription is necessary, give it a visible owner and teardown.

Incident clinic: one rollback undoes another user’s success

Consider this sequence:

Operation A optimistically approves loan 42 from version 7.
Operation B begins after a refresh and targets version 8.
Operation B succeeds and Store receives server version 9.
Operation A's delayed failure arrives and restores its snapshot of version 7.

The UI now displays stale state even though the server is correct. The reducer must compare operation identity before rollback, as shown earlier. It should also compare entity version or pending-command metadata so an old snapshot cannot replace a newer server entity.

A robust command entry can carry the operation and baseline:

export interface PendingCommand {
  kind: 'loading';
  requestId: string;
  expectedVersion: number;
  startedAt: number;
}

The failure handler restores only when the current command ID matches and the entity still represents the optimistic transition based on that expected version. Otherwise it records a diagnostic stale-outcome count and leaves newer state untouched.

Junior: Why not allow only one operation per loan forever?
>
Senior: The UI should prevent accidental overlap, but responses can be delayed, tabs can compete and other users exist. State transitions must remain correct even when the ideal interaction sequence breaks.
Write the exact action sequence as a reducer regression test. Then test the API boundary: a stale expected version produces 409 and returns or permits fetching current state. Browser protection and server protection solve different parts of the race.

During the incident, inspect operation IDs, entity versions and reducer acceptance in DevTools or safe telemetry. Do not conclude that the second user’s update was lost until checking the server. Repair the browser projection, notify affected users if necessary, and avoid replaying commands from DevTools against production systems.

Evolving an NgRx feature without a rewrite

State architecture should grow through evidence. Begin with local state. When several routes coordinate, move the shared workflow into a feature store. When arrays produce repetitive identity logic, introduce Entity. When server queries need independent caching, add query metadata. Each step answers observed pressure.

Migration needs one source of truth at a time. Suppose an existing LoansService exposes a BehaviorSubject. Add NgRx behind one route, route all new writes through Store/effects, and adapt remaining consumers to selectors. Do not update both subject and Store from every response indefinitely. Once consumers move, delete the subject and its mutation methods.

Actions are an internal event contract. Renaming them changes DevTools history and effects but not persisted business events unless the application deliberately exports them. Do not send raw NgRx actions to the server or message broker. Map to stable API commands.

State shape is also internal, yet persisted browser slices make it versioned data. If persistence is justified, add a schema version and migration:

interface PersistedLoansPreferencesV2 {
  schemaVersion: 2;
  pageSize: number;
  visibleColumns: string[];
}

Persist preferences, not loan entities or error history, unless a reviewed offline requirement demands more. Invalid or unknown persisted versions should fall back safely.

Mentoring review: can we remove NgRx here?

A useful architecture review also asks whether the pattern is still earning its cost.

Junior: We have actions and effects for opening a help panel. Is that consistent with the rest of the application?
>
Senior: It is syntactically consistent but probably the wrong lifetime. A component signal can own the panel. Global action history adds no meaningful coordination or debugging value.
For each slice, identify consumers, writers, lifetime, async workflow, cache policy and debugging need. If there is one component, one writer and no workflow, simplify. If a slice coordinates several routes, commands and races, retain the explicit flow.

Removal is not failure. A mature team uses NgRx where it makes state change understandable and chooses smaller tools elsewhere. Document the rule so developers do not debate from taste on every pull request.

The final test is explainability: a new engineer should trace a click through action, effect, API, outcome, reducer, selector and template in minutes. If they must search for hidden subscriptions or side effects in reducers, discipline has slipped. If they must open twenty ceremonial files for a boolean, the abstraction has expanded beyond the problem.

Finish the review by replaying one failure, one cancellation and one concurrency conflict. Confirm that Store shows the user-interface truth without pretending to be the server’s durable truth. Confirm that an ignored stale response is observable, that a denied command never appears as success, and that refreshing the page reconstructs a valid experience from URL, authenticated APIs and approved persisted preferences.

That is the standard worth carrying forward: every state value has an owner, every transition has a reason, every side effect has a lifecycle, and every race has an explicit winner. NgRx is successful when those rules make change safer—not merely when the application contains reducers.

Predictability is the outcome; Store is one carefully chosen means.

What I want you to take away

State management is not about memorising NgRx syntax.

It is about controlling change.

In a small Angular app, local component state and services are enough. But in a serious application — loan management, insurance, banking, healthcare, legal case management, property development — many screens depend on the same data. Users perform actions from different places. API calls complete later. Components need to agree. Debugging matters. Auditability matters. Predictability matters.

Redux gives us the pattern.

NgRx gives us the Angular implementation.

RxJS gives us the stream engine.

Actions describe what happened. Reducers calculate what state becomes. Effects handle the outside world. Selectors read state safely. Store becomes the single known place for important application state. Components become cleaner because they dispatch intent and render selected state.

That is the real lesson.

NgRx looks difficult until you realise it is just disciplined communication:

Component: something happened.
Action: here is what happened.
Effect: I will deal with the outside world.
Reducer: I will calculate the next state.
Store: I will hold the truth.
Selector: I will prepare what the UI needs.
Component: I will render it.

Once that clicks, NgRx stops feeling like ceremony and starts feeling like architecture.

Applied In

The thinking in this article has been applied throughout my enterprise portfolio, where architecture, workflows, permissions, notifications, reporting and modular design are all built around real business operations rather than isolated technical features.

View Technical Skills →

Use this journal entry for recall practice

Compare your explanation with the questions and working answers in my Practice Room.

Practise Angular, RxJS and state-management questions →
Afzal Ahmed

Faz Ahmed

Senior Full Stack Engineer & Technical Lead

A hands-on engineer with 15+ years in commercial software. I publish what I am studying, revising and testing so visitors can see both established experience and learning still in progress.

How would you approach this problem? I'd love to hear your thoughts or continue the discussion.

Connect on LinkedIn →