State management patterns.
apps/frontend/src/app/core/store/**vendix-zoneless-signals for critical Angular 20 rulesVendix uses a hybrid state model:
toSignal(..., { initialValue }).signal() state for component/service UI state.Legacy BehaviorSubject + destroy$ + ngOnDestroy service templates are not the primary pattern anymore.
initialValue where required.takeUntilDestroyed() in components/directives instead of ad-hoc destroy$ subjects when subscribing imperatively.If a module owns state/actions/*.actions.ts, every mutation goes through an
action. A component that calls the HTTP service directly bypasses the
action โ effect โ refresh chain, and the resulting bug is invisible in code
review because the POST itself succeeds โ only the list keeps showing stale data.
This is not hypothetical. It shipped as QUI-554: the store-users create modal
called StoreUsersManagementService.createUser() directly, so
StoreUsersActions.createUser was never dispatched, createUserSuccess was
never emitted, and the mutationSuccess$ effect โ which already existed and
reloads loadUsers + loadStats โ never ran. The user was persisted but the
table and the stat cards stayed on the old count until a manual page refresh.
| Concern | Owner |
|---|---|
| Firing the mutation | The component, via store.dispatch(createX({...})) |
| HTTP call, success/error toast | The effect (createX$) |
| Reloading list + stats after any mutation | One effect (mutationSuccess$), never a component |
| Closing the modal | The component, on ofType(createXSuccess) |
| Keeping the form on failure | The component, by not listening to createXFailure |
// modal โ dispatch, then wait for the outcome
private store = inject(Store);
private actions$ = inject(Actions);
readonly isSaving = this.store.selectSignal(selectEntitySaving);
constructor() {
this.actions$
.pipe(ofType(EntityActions.createEntitySuccess), takeUntilDestroyed(this.destroyRef))
.subscribe(() => { this.resetForm(); this.isOpen.set(false); this.created.emit(); });
}
onSubmit(): void {
if (this.form.invalid || this.isSaving()) return;
this.store.dispatch(EntityActions.createEntity({ entity: this.form.value }));
}
// effects โ the single refresh point
mutationSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(createEntitySuccess, updateEntitySuccess, deleteEntitySuccess),
switchMap(() => [loadEntities(), loadStats()]),
),
);
loadX/loadStats from a component after a mutation. One refresh owner: the effect.entity_saving), separate from the list-loading flag. Reusing entities_loading makes a save swap the table for the list spinner.output() that is declared but never emitted is worse than no output: onUserUpdated looked like the parent covered the refresh and hid QUI-554 for weeks. Emit it or delete it.model() already publishes an implicit xChange output. Declaring output<T>() with that same name creates two channels for one value and leaves the model desynchronized โ the parent's [(x)] then works by accident. Close with isOpen.set(false).scripts/state-refresh-audit.sh (CI job state-refresh-audit) fails when a
*.component.ts inside an NgRx-backed module calls
this.<x>Service.create|update|delete<Xxx>(...) and subscribes. It only fails on
files changed in the PR and reports pre-existing debt as a warning; --all runs
strict over the whole tree. Regression coverage lives in
store-users.effects.spec.ts and store-user-create-modal.component.spec.ts
(the modal spec asserts zero HTTP traffic via HttpTestingController).
vendix-zoneless-signalsvendix-frontendvendix-error-handling