State management (NgRx)
|
This project uses NgRx 4.1.1. |
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_CASEwith 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—UpdateSupplierModelhas a second constructor parametersupplierKey, andUpdateCrmModelhastargetUuidandalterKeys. Reducers readaction.supplierKeydirectly. Unusual, but load-bearing. -
Base classes are used to share payload shapes:
CrmItemService,CrmTaskService,CrmFetchListServiceincrm.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
constdeclarations insidecaseblocks without braces, so all theconst`s share the `switchblock scope. This compiles but means a name can only be used once per reducer. -
supplier.reducers.tsmutates 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 becauseChangeDetectionStrategy.OnPushis 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 returnsof()(an empty observable) if it is — unlesspayload.refreshis 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.navigateToSupplierreturns an array of three actions frommergeMap, which NgRx dispatches in order. This is how a supplier URL simultaneously repoints the supplier slice, the CRM slice and triggers the load. forkJoinfor 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
-
catchErrorreturns an_ERRORaction rather than rethrowing, so the stream survives. Errors are also logged through theLoggerhelper.
AppState
src/app/store/app.reducers.ts defines the entire root state — 12 slices,
nothing lazily added:
| Slice | Reducer | Holds |
|---|---|---|
|
|
|
|
|
The global key/value configuration collection plus a |
|
|
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 |
|
|
The current supplier plus a |
|
|
Normalised CRM timeline: |
|
|
Reference data (timezones, funding periods, US states, countries), persisted to
|
|
|
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 list state. |
|
|
Onboarding template state. |
|
|
A ring buffer of the last 50 HTTP requests, serialised, for debugging. |
|
|
Underwriting background-report state. Reducer-only — the effects file is empty. |
|
|
The newer underwriting slice, with |
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. BehaviorSubjectcaches in services-
UsersServicecaches users/groups inBehaviorSubject`s; `FeatureFlagsServicecaches global-settings flags the same way;GlobalSettingsServicereads 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.
|
|
Router-driven state
StoreRouterConnectingModule emits ROUTER_NAVIGATION. Two consumers matter:
SupplierEffects.navigateToSupplier-
Walks the activated-route tree looking for a
supplierKeyparameter (a recursivefilter(activatedRoot, NEEDED_KEY)helper) and, when found, dispatchesSET_SUPPLIER_KEY,SET_TARGET_UUIDandFETCH_SUPPLIER_DETAILS. This is why a supplier page needs no explicit load call in its component. layoutslice-
Fed by
LayoutUpdate(urlAfterRedirects)dispatched fromAppComponentonNavigationEnd(not from router-store), and matches the URL against regexes to select auserLayoutConfigentry.
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 |
|---|---|
|
Default state comes from |
|
Initial |
|
Default state comes from |
Full key list: caching and persistence.
Adding to the store
Only if more than one screen needs the state. Then:
-
Create
<module>/store/<slice>.actions.tswith type constants, action classes and a union type. Put selectors here unless the slice is new enough to warrant a*.selectors.ts. -
Create
<module>/store/<slice>.reducers.tswith an exported state interface, aninitialStateand aswitchreducer. -
If it needs I/O, create
<module>/store/<slice>.effects.tswith@Effect()properties andthis.actions$.ofType(…). -
Register the reducer in
AppStateandAPP_REDUCERSinsrc/app/store/app.reducers.ts. -
Register the effects class in
EffectsModule.forRoot([…])insrc/app/app.module.ts. -
Add a
*-mock.reducers.tsif 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.