Async State Machine
CngxAsyncState<T> is the standard interface for any value that arrives over time - HTTP responses, WebSocket pushes, user commits, server-driven autocompletes.
It is a bundle of signals, not a single signal of an object. Every field on the interface is a Signal<…>, so consumers read what they care about and the reactive graph tracks only that field.
The interface lives in @cngx/core/utils (file: projects/core/utils/async-state.ts) and underpins the entire feedback layer: toasts, banners, alerts, skeletons, empty states, loading indicators, refresh bars, commit errors.
UX state, not data state. CngxAsyncState<T> answers "what should the user see right now?" - not "what is the data?".
It drives skeleton, loading bar, toast, empty state, ARIA, focus. It does not replace SignalStore, NgRx, or any data store; it composes with them.
A store still owns the canonical entity cache; CngxAsyncState<T> is the lifecycle view that a single UI surface reads.
The status enum
type AsyncStatus =
| 'idle' // never loaded, no data, no error
| 'loading' // first load in flight, no data yet
| 'pending' // commit/mutation in flight (write path)
| 'refreshing' // re-fetch in flight, prior data still valid
| 'success' // data is current
| 'error'; // last operation failedSix values, three concerns:
- Read path -
idle,loading,success,error. - Re-fetch path -
refreshing(with priorsuccessdata still instate.data()). - Write path -
pending,success,error(intentional commit, optimistic or pessimistic).
The shape (every field is a Signal<…>):
interface CngxAsyncState<T> {
readonly status: Signal<AsyncStatus>;
readonly data: Signal<T | undefined>;
readonly error: Signal<unknown>;
readonly progress: Signal<number | undefined>;
// Derived booleans - every consumer can read these directly.
readonly isLoading: Signal<boolean>; // loading | pending | refreshing
readonly isPending: Signal<boolean>; // pending only
readonly isRefreshing: Signal<boolean>; // refreshing only
readonly isBusy: Signal<boolean>; // aria-busy alias for isLoading
readonly isFirstLoad: Signal<boolean>; // no successful load has completed
readonly isEmpty: Signal<boolean>; // data is empty / undefined
readonly hasData: Signal<boolean>; // data is present and non-empty
readonly isSettled: Signal<boolean>; // success | error
readonly lastUpdated: Signal<Date | undefined>;
}data() is undefined only while status() === 'idle' | 'loading'.
After the first successful load, data() stays defined even while status() === 'refreshing' | 'error' - this is what enables stale-data + inline-error UX patterns.
The isFirstLoad flag separates "never loaded" from "have data, just retrying".
Producers
A producer is anything that returns a CngxAsyncState<T>.
The interface and the low-level buildAsyncStateView kernel live in @cngx/core/utils. The application-level producers live in @cngx/common/data (re-exported from the root entry; they physically sit under projects/common/data/async-state/).
| Producer | Lives in | Use when |
|---|---|---|
buildAsyncStateView<T>(sources) |
@cngx/core/utils |
You already have separate status/data/error signals and want to assemble them into the standard interface. Used by CngxActionButton, the select family, and other bespoke organisms. |
createManualState<T>() |
@cngx/common/data |
You drive status transitions imperatively (typical inside a commit controller or a Web Worker pipeline). No injection context required. Returns ManualAsyncState<T> with set / setSuccess / setError / setProgress / reset. |
createAsyncState<T>() |
@cngx/common/data |
Explicit user-triggered mutation (POST/PUT/DELETE). Returns MutableAsyncState<T> with an execute(fn) method that runs the action, manages cancellation through an internal AbortController, and transitions through pending -> success/error. Requires an injection context. |
injectAsyncState<T>(fn, options?) |
@cngx/common/data |
Auto-loading reactive query. Re-runs fn when any signal it reads changes, debounced (default 50 ms). First call -> loading, subsequent calls -> refreshing. Returns ReactiveAsyncState<T> with a refresh() method. Requires an injection context. |
fromResource<T>(resource) |
@cngx/common/data |
Projects an Angular Resource<T> onto the CNGX shape. Maps idle/loading/reloading/resolved/local/error -> CNGX statuses; tracks isFirstLoad via an internal hadSuccess signal. Requires an injection context (uses effect()). |
fromHttpResource<T>(resource) |
@cngx/common/data |
Same as fromResource plus the HTTP progress signal mapped to progress (0-100, clamped, rounded). Declared structurally against HttpResourceLike<T> so the entry point does not import @angular/common/http. |
tapAsyncState, tapAsyncProgress, tapHttpAsyncState |
@cngx/common/data |
RxJS operators that update a ManualAsyncState alongside an existing pipeline. tapAsyncState sets loading on subscribe (override via { status }), setSuccess on next, setError on error (re-throws, does not swallow). tapHttpAsyncState adds progress and filters down to the response body. |
Pick by intent:
injectAsyncStatefor reactive HTTP fetches.createAsyncStatefor explicit mutations.createManualStatefor choreographed pipelines.fromHttpResourcefor anything already using AngularhttpResource.
Consumers
A consumer accepts [state] as an input.
The [state] input takes precedence over equivalent boolean inputs ([loading], [hasError], [empty]). Wire state once and the consumer derives all the booleans internally.
Surfaces that accept [state]
- Loading scaffolds -
cngx-skeleton,cngx-loading-overlay,cngx-loading-indicator,cngx-progress,cngx-empty-state. - Async content containers -
cngx-async-container,*cngxAsync,cngx-card-grid. - Inline feedback -
cngx-alert. - Overlays -
cngx-popover-panel,dialog[cngxDialog]. - Tables -
cngx-treetable. - Form controls -
cngx-select,cngx-multi-select,cngx-combobox,cngx-typeahead,cngx-tree-select,cngx-reorderable-multi-select,cngx-action-select,cngx-action-multi-select, plus the sharedcngx-select-shell. - Recycler -
injectRecycler({ state }).
Per-consumer derivation
Each consumer derives its own concern from the bound state:
cngx-skeletonshows placeholders whenstate.isFirstLoad()istrue. The skeleton owns the first-load phase and steps aside on refresh - prior data stays visible while the underlying query re-runs.cngx-empty-statehides itself whenstate.isLoading() || !state.isEmpty()and shows the empty message otherwise. The skeleton owns the loading phase; empty-state defers to it.*cngxAsyncandcngx-async-containerswitch view based onresolveAsyncView(...)(see below).- The select family routes its panel through
createSelectCore<T,TCommit>andresolveAsyncViewfor the panel content. - A
[cngxToastOn]/[cngxAlertOn]/[cngxBannerOn]bridge fires a transition handler onsuccess/error/idle.
resolveAsyncView()
resolveAsyncView is the function for deciding which UI surface to show.
It is a pure function exported from @cngx/common/data (source: projects/common/data/async-state/resolve-view.ts) and a sibling to AsyncView, the discriminated union it returns.
type AsyncView = 'none' | 'skeleton' | 'content' | 'empty' | 'error' | 'content+error';
function resolveAsyncView(status: AsyncStatus, firstLoad: boolean, empty: boolean): AsyncView;The lookup table
| status | firstLoad | empty | view |
|---|---|---|---|
idle |
true | * | none |
loading / refreshing / pending |
true | * | skeleton |
error |
true | * | error |
success |
false | true | empty |
error |
false | * | content+error |
| (all other) | false | * | content |
No separate 'idle' view exists. Idle on first load maps to 'none', which the consumer renders as a blank slate or a "press the button to load" prompt.
After the first successful load, isFirstLoad flips to false and the lookup falls through to content / empty / content+error.
Two error variants exist:
'content+error'is the stale-data-plus-inline-error case (status === 'error', prior data still indata()).'error'is the no-data-failed case (first load, no prior data).
Consumers call resolveAsyncView from a computed and switch on the result.
*cngxAsync collapses content+error to content because a structural directive cannot render two views at once; pair it with [cngxAlertOn] or [cngxBannerOn] for the inline-error half.
The select family wires the full six-variant switch through createSelectCore<T,TCommit> (source: projects/forms/select/shared/select-core.ts) into the shared panel-shell.component.ts.
Transition bridges
A transition bridge reacts to a status transition (idle -> success, loading -> error, refreshing -> error) and triggers an out-of-band notification.
All three are implemented on top of createTransitionTracker(() => effectiveState()?.status() ?? 'idle'), guard current() === previous() to skip non-transitions, and run their side effects inside untracked().
CNGX ships three, all as attribute directives (live in @cngx/ui/feedback):
[cngxToastOn]- fires aCngxToaster.show(...)on transition tosuccessorerror. Inputs:toastSuccess,toastError,toastErrorDetail,toastSuccessDuration,toastErrorDuration(default'persistent').[cngxAlertOn]- pushes an alert into the nearestCngxAlertStack(scoped via thealertScopeinput). Fires onsuccessand/orerrordepending on which message inputs are set.[cngxBannerOn]- callsCngxBanner.show(...)with a requiredbannerIddedup key on transition toerror; dismisses the samebannerIdon transition tosuccessoridle.
State binding
The state binding is the directive's primary input (aliased to the directive name itself):
<button [cngxToastOn]="saveState" toastSuccess="Saved" toastError="Save failed">Save</button>The state input is optional. When omitted (bare attribute cngxToastOn), the bridge falls back to inject(CNGX_STATEFUL, { optional: true })?.state from the host or any ancestor providing CNGX_STATEFUL.
The select family (all seven controls + the shared select-shell), the tabs presenter, the stepper presenter, and cngxChipInput provide CNGX_STATEFUL directly, which means:
<!-- cngx-select provides CNGX_STATEFUL - bridge auto-discovers state. -->
<cngx-select [commitAction]="save" [options]="options" cngxToastOn />Resolution order: state input -> CNGX_STATEFUL from DI -> afterNextRender dev-mode error if neither resolves.
A bare attribute (cngxToastOn with no value, or [cngxToastOn]="") is treated as "no input bound" via the input's empty-string transform and triggers the fallback.
The directive shape:
readonly state = input<
CngxAsyncState<unknown> | undefined, // ReadT - what the directive sees
CngxAsyncState<unknown> | '' | undefined // WriteT - what templates may bind
>(undefined, {
alias: 'cngxToastOn',
transform: (v) => (typeof v === 'string' ? undefined : v),
});The | '' in WriteT is mandatory. HTML attributes without a value bind the empty string, and signal inputs are stricter about that than legacy @Input().
The transform maps the empty string back to undefined, which the effectiveState computed then resolves to the CNGX_STATEFUL fallback.
The untracked rule for bridges
Transition bridges install an effect() that calls a service method (toaster.show(), banner.show(), alerter.show()). The service methods read signals internally.
Every bridge implementation reads only the tracker pair as tracked dependencies and wraps everything else - including the message/duration inputs and the service call itself - inside untracked():
const tracker = createTransitionTracker(() => this.effectiveState()?.status() ?? 'idle');
effect(() => {
const status = tracker.current();
const previous = tracker.previous();
if (status === previous) {
return;
}
untracked(() => {
if (status === 'success') {
this.toaster.show({ message: this.toastSuccess() ?? '' });
}
});
});A second trap: don't call .set() on any signal from inside a bridge effect that reads a transition tracker.
The four feedback bridges are safe because they call external service methods (toaster.show(), etc.), not signal writes.
The async-container is the documented exception. It writes an announcement signal from its tracker effect.
This only stays loop-free because the tracker's equal short-circuits identical-status re-runs (linkedSignal with equal: (a, b) => a.current === b.current && a.previous === b.previous).
CNGX_STATEFUL
CNGX_STATEFUL is the DI token that exposes a host component's state surface to descendant bridges and consumers.
The token and its interface live in projects/core/utils/stateful.ts:
interface CngxStateful<T = unknown> {
readonly state: CngxAsyncState<T>;
}
const CNGX_STATEFUL = new InjectionToken<CngxStateful>('CNGX_STATEFUL');Components that own an async state surface provide the token:
@Component({
selector: 'cngx-select',
providers: [{ provide: CNGX_STATEFUL, useExisting: CngxSelect }],
...
})
export class CngxSelect<T> implements CngxStateful<unknown> {
readonly state = ...; // a CngxAsyncState<unknown>
}This lets descendant bridges ([cngxToastOn], [cngxBannerOn], [cngxAlertOn]) and any custom consumer reach the state without an explicit binding.
Current providers:
- The select family -
CngxSelect,CngxMultiSelect,CngxCombobox,CngxTypeahead,CngxTreeSelect,CngxReorderableMultiSelect,CngxActionSelect,CngxActionMultiSelect, and the sharedCngxSelectShell. - The tabs presenter.
- The stepper presenter.
CngxChipInput.
Producer-consumer composition
The common pattern: a producer creates a state, a consumer renders it, a bridge handles notifications. They are wired by composition, not by configuration.
// Producer (component code)
readonly users = injectAsyncState(() => this.api.listUsers());
// Consumer + bridge (template)
<cngx-async-container [state]="users" [cngxToastOn]="users" toastError="Could not load users.">
<ng-template cngxAsyncContent let-data>
@for (u of data; track u.id) { <user-row [user]="u" /> }
</ng-template>
</cngx-async-container>The producer emits transitions, the container picks the right view via resolveAsyncView, the bridge fires the toast on error - all from one state reference, with no subscriptions or manual flag wiring.
Bootstrap: provideFeedback()
The bridges depend on services (CngxToaster, CngxBanner, CngxAlerter) that are not providedIn: 'root'.
Wire them once at the application root with provideFeedback from @cngx/ui/feedback:
bootstrapApplication(AppComponent, {
providers: [
provideFeedback(
withToasts({ defaultDuration: 3000, dedupWindow: 500 }),
withAlerts({ maxVisible: 3 }),
withBanners(),
withSpinnerTemplate(MySpinner),
withAlertIcons({ success: SuccessIcon, error: ErrorIcon }),
withLoadingDefaults({ delay: 300, minDuration: 600 }),
withCloseIcon(MyCloseIcon),
),
],
});Each feature is opt-in.
Forgetting withToasts() while using [cngxToastOn] throws a constructor error with the fix in the message: "CngxToaster not found. Add withToasts() to provideFeedback() or call provideToasts() in your providers."
What NOT to do
Do not roll your own ad-hoc state shape (
isLoading$,errorMsg,data) whenCngxAsyncState<T>already covers it.The bundled
isLoading/isPending/isRefreshing/isFirstLoad/isSettledsignals are part of the interface.Do not bind
[loading]="state.isLoading()"when[state]="state"works. The consumer derives loading/empty/error fromstateinternally and selects the right view.Do not wrap a
CngxAsyncStatein anotherSignal. The interface IS the signal bundle -Signal<CngxAsyncState<T>>is one indirection too many.Do not forget
untracked()inside a bridge effect or any effect that calls a service. The service reads signals internally and the missinguntrackedproduces an infinite loop.Do not invent a new producer when
createManualStateorinjectAsyncStatecovers the case.createManualStateis the commit-controller default.