State management (NgRx)

This project uses NgRx 4.1.1. createAction, createReducer, createEffect, @ngrx/entity and the ActionCreator type do not exist in this version. Copy the existing patterns; do not import modern NgRx idioms.

The NgRx 4 idioms

Actions

Action types are exported string constants; actions are classes implementing Action with a readonly type; a union type collects them for the reducer.

// suppliers/store/supplier/supplier.actions.ts
export const FETCH_SUPPLIER_DETAILS = 'FETCH_SUPPLIER_DETAILS';

export class FetchSupplierDetails implements Action {
  readonly type = FETCH_SUPPLIER_DETAILS;
  constructor(public payload: { supplierKey: string; refresh?: boolean }) {}
}

export type Actions = FetchSupplierDetails | UpdateSupplierModel | /* … */;

Conventions actually followed:

  • Type strings are SCREAMING_SNAKE_CASE with no [Feature] prefix.

  • The triplet convention is X, X_SUCCESS, X_ERROR — the base type is the request, not the result.

  • Some actions carry data outside payload — UpdateSupplierModel has a second constructor parameter supplierKey, and UpdateCrmModel has targetUuid and alterKeys. Reducers read action.supplierKey directly. Unusual, but load-bearing.

  • Base classes are used to share payload shapes: CrmItemService, CrmTaskService, CrmFetchListService in crm.actions.ts.

Selectors

Selectors live in the actions file, not a separate selectors.ts, except in the newer slices (global-settings, underwriting) which do have *.selectors.ts. Both conventions are present; match the slice you are editing.

const selectAuth = (state: AppState): AuthState => state.auth;

export const selectAuthToken = createSelector(selectAuth, (state) => {
  if (hasCorrectToken(state)) { return state.user.jwtToken; }
});

Note the pattern of returning undefined implicitly rather than null when the guard fails — consumers therefore use filter(Boolean) or filter(x ⇒ !!x) downstream rather than null checks.

A selector factory pattern appears twice and is worth knowing:

// utils.actions.ts — one selector per reference-data attribute
function selectUtilsAttribute(attr: string) {
  return createSelector(selectUtilsState, (state) =>
    state?.hasOwnProperty(attr) ? state[attr] : undefined);
}
export const selectTimezones = selectUtilsAttribute('timezones');

// crm.actions.ts — one selector per timeline stream
function createTimelineStreamSelector(streamName: string) { /* … */ }
export const selectCrmNotes = createTimelineStreamSelector('notes');

Reducers

Plain functions with a switch. Two things to watch:

  • Several reducers use const declarations inside case blocks without braces, so all the const`s share the `switch block scope. This compiles but means a name can only be used once per reducer.

  • supplier.reducers.ts mutates model objects in places (supplierDetailsModel.hide = !supplierDetailsModel.hide, supplierModel.marketplaces[index] = …, delete state.supplierModel.marketplaces) while spreading the surrounding state. This breaks strict immutability. It works because ChangeDetectionStrategy.OnPush is not used broadly, but it is a real hazard: do not rely on reference equality of nested supplier objects.

One slice uses a map-of-handlers instead of a switch — shared/interceptors/fetched-requests.reducers.ts builds a Map from action type to handler function. A third style; not worth propagating.

Effects

@Injectable()
export class SupplierEffects {
  @Effect()
  getSupplierDetails = this.actions$.ofType(FETCH_SUPPLIER_DETAILS).pipe(
    withLatestFrom(this.store$),
    switchMap(([action, state]) => /* … */),
  );

  @Effect({ dispatch: false })
  authLogout = this.actions$.ofType(LOGOUT).pipe(tap(/* side effect only */));
}

Recurring idioms:

Cache-check with withLatestFrom(this.store$)

Nearly every fetch effect pulls the whole AppState, checks whether the aggregate is already present, and returns of() (an empty observable) if it is — unless payload.refresh is set. This is the app’s caching strategy for domain data.

Returning plain action literals

Effects frequently emit { type: UPDATE_SUPPLIER_MODEL, supplierKey, payload } object literals rather than constructing action classes. Both forms coexist.

Fan-out from one action

SupplierEffects.navigateToSupplier returns an array of three actions from mergeMap, which NgRx dispatches in order. This is how a supplier URL simultaneously repoints the supplier slice, the CRM slice and triggers the load.

forkJoin for aggregate loads

The supplier aggregate is four parallel calls; permission-dependent ones are replaced with of(null) rather than being conditionally excluded, so the tuple shape stays fixed.

Error handling inside the stream

catchError returns an _ERROR action rather than rethrowing, so the stream survives. Errors are also logged through the Logger helper.

AppState

src/app/store/app.reducers.ts defines the entire root state — 12 slices, nothing lazily added:

Slice Reducer Holds

auth

authReducers

isAuthenticated, the raw user object (including jwtToken and perms), and authError. Rehydrated from localStorage['currentUser'] at reducer-init time.

globalSettings

globalSettingsReducers

The global key/value configuration collection plus a loading flag. Has its own .selectors.ts and .state.ts.

layout

layoutReducer

Shell presentation: CRM panel state, supplier-info panel state, search-button and actions visibility, chosen search engine, app-stage label, current route, and a per-route-pattern userLayoutConfig.

supplier

supplierReducers

The current supplier plus a suppliers cache keyed by supplier key, the part-wise edit maps, per-operation progress/error flags, payment-config statuses, card providers, supplier cards and Airwallex state. Also persists the last supplier-list query to localStorage.

crm

crmReducers

Normalised CRM timeline: crmItems (uuid → item), per-type uuid streams, a per-target cache, the current targetUuid, and pending-item statuses.

utils

utilsReducers

Reference data (timezones, funding periods, US states, countries), persisted to localStorage via lscache with a 24-hour TTL.

calc

calculatorReducers

The advance-offer pricing calculator: periods, requested/recommended/approved amounts, totals, validation flags, risk rating, grace period, return strategy. The largest piece of pure business arithmetic in the front end.

marketplace

marketplacesReducer

Marketplace list state.

onboarding

onboardingReducer

Onboarding template state.

errors

errorsReducer (IFetchedRequestsState)

A ring buffer of the last 50 HTTP requests, serialised, for debugging.

backgroundReports

backgroundReportsReducer

Underwriting background-report state. Reducer-only — the effects file is empty.

underwriting

underwritingReducers

The newer underwriting slice, with .state.ts and .selectors.ts.

The per-slice action catalogue is in the store action catalogue.

What is not in the store

Most feature state is component-local: report tables, manage screens, marketplace forms, most dialogs, all grid state. The store is reserved for state that crosses screens.

Two non-NgRx sharing mechanisms exist alongside it:

TableEventsService

A Subject-based publish/subscribe bus with string event names — ADD_TRANSACTION_EVENT, CRM_OPEN_ALL_TAB, SIDENAV_CLOSED_EVENT, TASK_COUNTERS_UPDATE. Used where a store action would be overkill. See the in-app event bus.

BehaviorSubject caches in services

UsersService caches users/groups in BehaviorSubject`s; `FeatureFlagsService caches global-settings flags the same way; GlobalSettingsService reads through the store but is itself the API. This means some shared state lives in services rather than the store — check the service before adding a slice.

GlobalSettingsService both dispatches store actions and returns observables derived from the store. It is a facade over the slice, so call the service; do not select globalSettings directly from a component.

Router-driven state

StoreRouterConnectingModule emits ROUTER_NAVIGATION. Two consumers matter:

SupplierEffects.navigateToSupplier

Walks the activated-route tree looking for a supplierKey parameter (a recursive filter(activatedRoot, NEEDED_KEY) helper) and, when found, dispatches SET_SUPPLIER_KEY, SET_TARGET_UUID and FETCH_SUPPLIER_DETAILS. This is why a supplier page needs no explicit load call in its component.

layout slice

Fed by LayoutUpdate(urlAfterRedirects) dispatched from AppComponent on NavigationEnd (not from router-store), and matches the URL against regexes to select a userLayoutConfig entry.

Persistence in reducers

Three reducers read and write browser storage at module-evaluation time or inside the reducer body, which is unusual and worth flagging:

Reducer Storage behaviour

authReducers

Default state comes from getPersistedState(), which parses localStorage['currentUser']. LOGIN writes it back. LOGOUT/CLEAR_AUTH_STATE reset to initialState — but the actual localStorage.clear() happens in AuthEffects, not the reducer.

supplierReducers

Initial lastQueryString comes from localStorage['suppliers_last_query'], or falls back to created_since:<today − 1 day>. SET_SUPPLIERS_QUERY_STRING writes it back.

utilsReducers

Default state comes from lscache.get('utilsState'); SET_UTIL_ATTR saves the whole slice back with a 24-hour TTL.

Full key list: caching and persistence.

Adding to the store

Only if more than one screen needs the state. Then:

  1. Create <module>/store/<slice>.actions.ts with type constants, action classes and a union type. Put selectors here unless the slice is new enough to warrant a *.selectors.ts.

  2. Create <module>/store/<slice>.reducers.ts with an exported state interface, an initialState and a switch reducer.

  3. If it needs I/O, create <module>/store/<slice>.effects.ts with @Effect() properties and this.actions$.ofType(…​).

  4. Register the reducer in AppState and APP_REDUCERS in src/app/store/app.reducers.ts.

  5. Register the effects class in EffectsModule.forRoot([…​]) in src/app/app.module.ts.

  6. Add a *-mock.reducers.ts if specs will need a stub — the existing slices all have one (auth-mock.reducers.ts, crm-mock.reducers.ts, supplier-mock.reducers.ts, utils-mock.reducets.ts — note the typo in that filename).

Testing patterns are in testing; the calculator slice has the most thorough spec (calculator.spec.ts, ~560 lines) and is the best model to copy.