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. |
|
|
Complete slice map
| Key | Registered by | Contents |
|---|---|---|
|
|
Serialized router state |
|
|
Session, user, dashboard status |
|
|
Calculated |
|
|
Available balance |
|
|
Configured payout methods, including p-cards |
|
|
Contact and residential details |
|
|
Legal entity details |
|
|
Plaid Link session state |
|
|
Authy enrolment and challenge state |
|
|
Advance Line summary |
|
|
Available Advance Line offers |
|
|
Instant Advance product data |
|
|
Repayment schedule and history |
|
|
Fee schedule |
|
|
Server-driven notification set |
|
|
CMS blog posts |
|
|
Product offerings summary |
|
|
Airwallex card collection |
|
|
Card ordering wizard state |
|
|
Entity — the supplier’s connected channels |
|
|
Entity — the catalog of supported marketplaces |
|
|
Entity — country reference data |
|
|
Entity — onboarding step progress |
|
|
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.
|
|
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.
|
|
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 |
|---|---|
|
Only the latest request matters — reloads, refreshes. |
|
Order matters and nothing may be dropped — sequenced dispatches. |
|
Requests are independent and can overlap. |
|
Ignore new triggers while one is in flight — submit buttons, |
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.