Skip to content

State Management

Each remote app manages its own NgRx store independently. There is no global store shared across remote apps. This page covers the store structure pattern, how actions, reducers, effects, and selectors are organised, and the rationale for local-only state.


Design Principle: No Global Store

Each remote app owns its own store — there is no cross-remote store

NgRx state is scoped to each remote app. The host (erp-home) does not share a store with apps-accounting or any other remote. This is a deliberate consequence of the Module Federation deployment model.

Why local store per remote?

  1. Independent deployability. If apps-accounting deploys a new NgRx action or state shape, it cannot break apps-hr's store — they are entirely separate.
  2. No version coupling. NgRx version mismatches across remotes would cause reducer conflicts if a shared store were used.
  3. Simpler mental model. Developers working on the accounting module only need to understand the accounting store.

Cross-module data exchange is handled by the backend API, not by sharing NgRx state between remotes.


Store Structure Pattern

Each remote app follows the same folder layout for its NgRx state:

apps/apps-accounting/src/app/
├── state/
│   ├── invoice/
│   │   ├── invoice.actions.ts
│   │   ├── invoice.reducer.ts
│   │   ├── invoice.effects.ts
│   │   ├── invoice.selectors.ts
│   │   └── invoice.state.ts        # State interface
│   ├── journal-entry/
│   │   ├── journal-entry.actions.ts
│   │   ├── journal-entry.reducer.ts
│   │   ├── journal-entry.effects.ts
│   │   └── journal-entry.selectors.ts
│   └── index.ts                    # Root reducer map for this remote
└── accounting.module.ts

State Interface

Define the state shape in a dedicated *.state.ts file:

typescript
// state/invoice/invoice.state.ts
import { Invoice } from '../../models/invoice.model';

export interface InvoiceState {
  invoices: Invoice[];
  selectedInvoice: Invoice | null;
  loading: boolean;
  error: string | null;
  totalCount: number;
  currentPage: number;
}

export const initialInvoiceState: InvoiceState = {
  invoices: [],
  selectedInvoice: null,
  loading: false,
  error: null,
  totalCount: 0,
  currentPage: 1,
};

Actions

Actions follow the [Feature] Action Name convention. Use createAction and props for type safety.

typescript
// state/invoice/invoice.actions.ts
import { createAction, props } from '@ngrx/store';
import { Invoice } from '../../models/invoice.model';
import { GetAllInvoicesQuery } from '../../models/invoice-query.model';

// Load list
export const loadInvoices = createAction(
  '[Invoice] Load Invoices',
  props<{ query: GetAllInvoicesQuery }>()
);
export const loadInvoicesSuccess = createAction(
  '[Invoice] Load Invoices Success',
  props<{ invoices: Invoice[]; totalCount: number }>()
);
export const loadInvoicesFailure = createAction(
  '[Invoice] Load Invoices Failure',
  props<{ error: string }>()
);

// Load single
export const loadInvoiceById = createAction(
  '[Invoice] Load Invoice By Id',
  props<{ id: number }>()
);
export const loadInvoiceByIdSuccess = createAction(
  '[Invoice] Load Invoice By Id Success',
  props<{ invoice: Invoice }>()
);
export const loadInvoiceByIdFailure = createAction(
  '[Invoice] Load Invoice By Id Failure',
  props<{ error: string }>()
);

// Mutations
export const addInvoice = createAction(
  '[Invoice] Add Invoice',
  props<{ dto: AddInvoiceDto }>()
);
export const addInvoiceSuccess = createAction(
  '[Invoice] Add Invoice Success',
  props<{ id: number }>()
);
export const addInvoiceFailure = createAction(
  '[Invoice] Add Invoice Failure',
  props<{ error: string }>()
);

export const postInvoice = createAction(
  '[Invoice] Post Invoice',
  props<{ id: number }>()
);
export const postInvoiceSuccess = createAction('[Invoice] Post Invoice Success');
export const postInvoiceFailure = createAction(
  '[Invoice] Post Invoice Failure',
  props<{ error: string }>()
);

// UI
export const clearSelectedInvoice = createAction('[Invoice] Clear Selected Invoice');
export const setCurrentPage = createAction(
  '[Invoice] Set Current Page',
  props<{ page: number }>()
);

Reducer

typescript
// state/invoice/invoice.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { initialInvoiceState } from './invoice.state';
import * as InvoiceActions from './invoice.actions';

export const invoiceReducer = createReducer(
  initialInvoiceState,

  on(InvoiceActions.loadInvoices, state => ({
    ...state,
    loading: true,
    error: null,
  })),

  on(InvoiceActions.loadInvoicesSuccess, (state, { invoices, totalCount }) => ({
    ...state,
    invoices,
    totalCount,
    loading: false,
  })),

  on(InvoiceActions.loadInvoicesFailure, (state, { error }) => ({
    ...state,
    loading: false,
    error,
  })),

  on(InvoiceActions.loadInvoiceByIdSuccess, (state, { invoice }) => ({
    ...state,
    selectedInvoice: invoice,
    loading: false,
  })),

  on(InvoiceActions.clearSelectedInvoice, state => ({
    ...state,
    selectedInvoice: null,
  })),

  on(InvoiceActions.setCurrentPage, (state, { page }) => ({
    ...state,
    currentPage: page,
  })),
);

Reducers must be pure functions — no side effects, no mutations.


Effects

Effects handle asynchronous operations (API calls, navigation, toast messages).

typescript
// state/invoice/invoice.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { InvoiceService } from '../../services/invoice.service';
import { ToastService } from 'shared-lib';
import { TranslateService } from '@ngx-translate/core';
import * as InvoiceActions from './invoice.actions';
import { switchMap, map, catchError, tap } from 'rxjs/operators';
import { of } from 'rxjs';

@Injectable()
export class InvoiceEffects {
  loadInvoices$ = createEffect(() =>
    this.actions$.pipe(
      ofType(InvoiceActions.loadInvoices),
      switchMap(({ query }) =>
        this.invoiceService.getAll(query).pipe(
          map(result =>
            InvoiceActions.loadInvoicesSuccess({
              invoices: result.data,
              totalCount: result.totalCount,
            })
          ),
          catchError(error =>
            of(InvoiceActions.loadInvoicesFailure({ error: error.message }))
          )
        )
      )
    )
  );

  loadInvoiceById$ = createEffect(() =>
    this.actions$.pipe(
      ofType(InvoiceActions.loadInvoiceById),
      switchMap(({ id }) =>
        this.invoiceService.getById(id).pipe(
          map(invoice => InvoiceActions.loadInvoiceByIdSuccess({ invoice })),
          catchError(error =>
            of(InvoiceActions.loadInvoiceByIdFailure({ error: error.message }))
          )
        )
      )
    )
  );

  addInvoiceSuccess$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(InvoiceActions.addInvoiceSuccess),
        tap(() => {
          this.toast.success(this.translate.instant('shared_success'));
        })
      ),
    { dispatch: false }
  );

  failure$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(
          InvoiceActions.loadInvoicesFailure,
          InvoiceActions.addInvoiceFailure,
          InvoiceActions.postInvoiceFailure
        ),
        tap(({ error }) => {
          this.toast.error(error ?? this.translate.instant('shared_error'));
        })
      ),
    { dispatch: false }
  );

  constructor(
    private actions$: Actions,
    private invoiceService: InvoiceService,
    private toast: ToastService,
    private translate: TranslateService
  ) {}
}

Selectors

Selectors derive data from the store. Always use createSelector for memoisation.

typescript
// state/invoice/invoice.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { InvoiceState } from './invoice.state';

export const selectInvoiceState = createFeatureSelector<InvoiceState>('invoice');

export const selectInvoices = createSelector(
  selectInvoiceState,
  state => state.invoices
);

export const selectSelectedInvoice = createSelector(
  selectInvoiceState,
  state => state.selectedInvoice
);

export const selectInvoiceLoading = createSelector(
  selectInvoiceState,
  state => state.loading
);

export const selectInvoiceError = createSelector(
  selectInvoiceState,
  state => state.error
);

export const selectInvoiceTotalCount = createSelector(
  selectInvoiceState,
  state => state.totalCount
);

export const selectPostedInvoices = createSelector(
  selectInvoices,
  invoices => invoices.filter(i => i.status === 'Posted')
);

Store Registration

Register the remote's feature store in its root module:

typescript
// apps/apps-accounting/src/app/accounting.module.ts
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { invoiceReducer } from './state/invoice/invoice.reducer';
import { InvoiceEffects } from './state/invoice/invoice.effects';
import { journalEntryReducer } from './state/journal-entry/journal-entry.reducer';
import { JournalEntryEffects } from './state/journal-entry/journal-entry.effects';

@NgModule({
  imports: [
    StoreModule.forFeature('invoice', invoiceReducer),
    StoreModule.forFeature('journalEntry', journalEntryReducer),
    EffectsModule.forFeature([InvoiceEffects, JournalEntryEffects]),
  ],
})
export class AccountingModule {}

The host app registers the root store and effects module:

typescript
// apps/erp-home/src/app/app.module.ts
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from '../environments/environment';

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

Using the Store in Components

typescript
// apps/apps-accounting/src/app/invoices/invoice-list/invoice-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { Invoice } from '../../models/invoice.model';
import * as InvoiceActions from '../../state/invoice/invoice.actions';
import * as InvoiceSelectors from '../../state/invoice/invoice.selectors';

@Component({
  selector: 'app-invoice-list',
  templateUrl: './invoice-list.component.html',
})
export class InvoiceListComponent implements OnInit {
  invoices$: Observable<Invoice[]> = this.store.select(InvoiceSelectors.selectInvoices);
  loading$: Observable<boolean>   = this.store.select(InvoiceSelectors.selectInvoiceLoading);
  total$: Observable<number>      = this.store.select(InvoiceSelectors.selectInvoiceTotalCount);

  constructor(private store: Store) {}

  ngOnInit(): void {
    this.store.dispatch(InvoiceActions.loadInvoices({ query: { page: 1, pageSize: 20 } }));
  }

  onPageChange(page: number): void {
    this.store.dispatch(InvoiceActions.setCurrentPage({ page }));
    this.store.dispatch(InvoiceActions.loadInvoices({ query: { page, pageSize: 20 } }));
  }
}

When NOT to Use NgRx

Not every state needs the store. Use NgRx only for state that:

  • Is shared between multiple components within the same remote
  • Needs to survive navigation (e.g., filter state when the user goes to a detail and comes back)
  • Involves asynchronous operations with loading/error states

Use component-level state (simple class properties or BehaviorSubject) for:

  • Transient UI state (dialog open/closed, form dirty flag)
  • State used only within a single component
  • Short-lived display state that resets on navigation

Internal Documentation — Microtec Platform Team