NgRX Overview

The application uses NgRX 12: @ngrx/store, @ngrx/effects, @ngrx/entity and @ngrx/router-store.

Root configuration

AppModule registers a deliberately empty root reducer map — only the router. Everything else attaches through StoreModule.forFeature().

// src/app/core/reducers/index.ts
export type AppState = Record<string, any>;

export const reducers: ActionReducerMap<AppState> = { router: routerReducer };

export const metaReducers: MetaReducer<AppState>[] =
    !environment.production ? [storeFreeze] : [];
// src/app/app.module.ts
StoreModule.forRoot(reducers, {
    metaReducers,
    runtimeChecks: {
        strictActionImmutability: false,   (1)
        strictStateImmutability: false,
    },
}),
EffectsModule.forRoot([]),
StoreRouterConnectingModule.forRoot({ stateKey: 'router' }),
StoreDevtoolsModule.instrument({ logOnly: true }),
1 NgRX’s own runtime checks are disabled because storeFreeze already enforces immutability outside production, and the built-in checks produced false positives against the class-based models used here.

AppState is Record<string, any> rather than a hand-maintained interface. Type safety comes from the per-slice State interfaces and their selectors, not from the root type.

Complete slice map

Key Registered by Contents

router

AppModule (root)

Serialized router state

auth

AuthModule

Session, user, dashboard status

permissions

ThemeModule

Calculated Permission tree

balance

BalanceFeatureModule

Available balance

paymentMethods

PaymentMethodsFeatureModule

Configured payout methods, including p-cards

personalInformation

PersonalInformationFeatureModule

Contact and residential details

businessInformation

BusinessInformationFeatureModule

Legal entity details

plaid

PlaidFeatureModule

Plaid Link session state

2fa

TwoFactorAuthenticationFeatureModule

Authy enrolment and challenge state

advanceLine

AdvanceLineFeatureModule

Advance Line summary

advanceLineOffers

AdvanceLineFeatureModule

Available Advance Line offers

instantAdvance

InstantAdvanceFeatureModule

Instant Advance product data

instantAdvanceRepayment

InstantAdvanceRepaymentModule

Repayment schedule and history

fees

FeesStoreModule

Fee schedule

dashboardNotifications

DashboardNotificationsFeatureModule

Server-driven notification set

blog

BlogStoreModule

CMS blog posts

productSummary

ProductSummaryModule

Product offerings summary

airwallexCards

AirwallexCardsFeatureModule

Airwallex card collection

cardOrder

CardOrderFeatureModule

Card ordering wizard state

supplierMarketplaces

SupplierMarketplacesFeatureModule

Entity — the supplier’s connected channels

marketplaces

MarketplacesFeatureModule

Entity — the catalog of supported marketplaces

countries

CountriesFeatureModule

Entity — country reference data

onboarding

OnboardingFeatureModule

Entity — onboarding step progress

reports.dailySummaryReports

DailySummaryReportModule

Entity — paginated daily summaries

FeaturesModule (core/pyb/features/features.module.ts) imports and re-exports every slice module, and is itself imported once by AppModule. Adding a slice means adding it in two places inside that file — imports and exports.

reports.dailySummaryReports contains a dot, which reads like a nested key but is a single flat feature key. Select it with createFeatureSelector('reports.dailySummaryReports'), not by walking into a reports object.

Immutability

storeFreeze is applied as a meta-reducer outside production. It deep-freezes state and actions, so any accidental mutation throws immediately in development and CI.

storeFreeze is not applied in production. A mutation that slips past review fails loudly in dev and silently corrupts state in prod. Always return new objects from reducers.

Slice anatomy

Every feature folder follows the same shape:

features/<name>/
├── <name>.actions.ts     createAction definitions
├── <name>.reducer.ts     featureKey, State interface, initialState, createReducer
├── <name>.selectors.ts   createFeatureSelector + createSelector
├── <name>.effects.ts     API calls and other side effects
├── <name>.module.ts      StoreModule.forFeature + EffectsModule.forFeature
├── <name>.enums.ts       action-type strings and slice-local enums (optional)
├── <name>.interfaces.ts  payload types (optional)
└── index.ts              barrel

Actions

export const loadBalance        = createAction('[Balance] Load Balance');
export const loadBalanceSuccess = createAction('[Balance] Load Balance Success', props<{ balance: BalanceDto }>());
export const loadBalanceError   = createAction('[Balance] Load Balance Error',   props<{ error: HttpErrorResponse }>());

Names follow [Feature] Description. Larger slices hoist the strings into an enum (DashboardNotificationsActionTypes, TwoFactorAuthenticationActionTypes, AuthActionTypes) so effects and reducers cannot drift apart.

Reducers

export const balanceFeatureKey = 'balance';

export interface State { balance: number; loading: boolean; error: boolean; }

export const initialState: State = { balance: null, loading: false, error: false };

const balanceReducer = createReducer(
    initialState,
    on(loadBalance,        state => ({ ...state, loading: true,  error: false })),
    on(loadBalanceSuccess, (state, { balance }) => ({ ...state, balance, loading: false })),
    on(loadBalanceError,   state => ({ ...state, loading: false, error: true })),
);

export function reducer(state: State | undefined, action: Action) {
    return balanceReducer(state, action);
}

The exported reducer wrapper exists for AOT compatibility on Angular 12 — createReducer results cannot be referenced directly in a decorator metadata expression.

Most slices carry loading / loaded / error flags. Guards depend on them: SellerCardListGuard waits for loaded && !loading on two slices before deciding where to route.

Selectors

export const selectBalanceState = createFeatureSelector<State>(balanceFeatureKey);
export const selectBalance      = createSelector(selectBalanceState, s => s.balance);
export const selectIsLoading    = createSelector(selectBalanceState, s => s.loading);

Selectors are the only public read surface. Components never index into state.

Effects

loadBalance$ = createEffect(() =>
    this.actions$.pipe(
        ofType(loadBalance),
        switchMap(() => this.balanceService.get().pipe(
            map(balance => loadBalanceSuccess({ balance })),
            catchError(error => of(loadBalanceError({ error }))),
        )),
    ),
);

Operator choice is meaningful:

Operator Used when

switchMap

Only the latest request matters — reloads, refreshes.

concatMap

Order matters and nothing may be dropped — sequenced dispatches.

mergeMap

Requests are independent and can overlap.

exhaustMap

Ignore new triggers while one is in flight — submit buttons, DocuSignGuard.

Effects that only produce side effects declare { dispatch: false }, as AuthEffects.logout$ does.

Router state

StoreRouterConnectingModule.forRoot({ stateKey: 'router' }) mirrors router state into the store, so effects can react to navigation and time-travel debugging replays routes.

DevTools

StoreDevtoolsModule.instrument({ logOnly: true }) — the Redux DevTools extension can inspect and replay, but dispatching from the extension is disabled.