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. |
createAggregateAsyncState<T>(sources) |
@cngx/core/utils |
You have N independent CngxAsyncStates and want one derived state: loading while any loads, errored on the first failure, empty only when all are empty, success only when all succeed. Pure computed() over the data() of each source; the result is itself a CngxAsyncState. Wire the keyed list through CngxAsyncBoundary (see Aggregating multiple states). |
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). An aggregate is just anotherCngxAsyncState, socngx-async-containerrenders one unchanged - driven byCngxAsyncBoundaryover itsstate(see Aggregating multiple states).- 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.
Aggregating multiple states
A screen often depends on several independent async sources at once (user + permissions + feature flags, or three parallel resources behind one panel). createAggregateAsyncState derives one CngxAsyncState from N of them, so the whole screen renders through a single consumer instead of hand-rolled nested @if blocks.
createAggregateAsyncState<T>(sources: Signal<readonly CngxAsyncState<unknown>[]>) is a pure factory in @cngx/core/utils (source: projects/core/utils/aggregate-async-state.ts). It reuses buildAsyncStateView, so every derived flag (isLoading, isSettled, hasData, ...) stays single-source-consistent with every other producer - a consumer cannot tell an aggregate from a leaf state.
The status rule
Combined status follows a fixed priority (first match wins):
| Priority | Condition | Combined status |
|---|---|---|
| 1 | any source error |
error |
| 2 | any source loading / pending |
loading |
| 3 | any source refreshing |
refreshing |
| 4 | every source success |
success |
| 5 | otherwise (empty list, or any remaining idle) |
idle |
data is the per-source data() in input order (each element T | undefined, since a source carries no data until it reaches success). Emptiness cannot be read off the data shape - an N-element array is never length 0 - so the aggregate defines empty as at least one source and every source itself empty, and supplies that signal explicitly.
Wiring it: CngxAsyncBoundary
CngxAsyncBoundary ([cngxAsyncBoundary], @cngx/common/data) is the headless directive that takes the keyed source list, builds the aggregate, and provides CNGX_STATEFUL. It exposes state (the aggregate) and failures (the errored sources, keyed for attribution).
<div [cngxAsyncBoundary]="sources()" #b="cngxAsyncBoundary" cngxToastOn [toastError]="'Bootstrap failed'">
<cngx-async-container [state]="b.state">
<ng-template cngxAsyncContent let-data> ...render the ordered data... </ng-template>
</cngx-async-container>
@for (f of b.failures(); track f.key) {
<cngx-alert severity="error" [title]="f.label ?? f.key">{{ f.error }}</cngx-alert>
}
</div>Two channels, no conflict:
- The aggregate's own
errorstays the first error in input order - the single "something failed" that a nestedcngxToastOn/cngxAlertOn/cngxBannerOnbridge picks up viaCNGX_STATEFUL, with zero[state]wiring. failures()is the persistent per-source breakdown - keyed, so a consumer's@forattributes each failure through any feedback component.
Because the aggregate is a CngxAsyncState, cngx-async-container renders it through the same four-slot switch as any leaf state, and every existing consumer and bridge composes unchanged.
Loading timing
resolveAsyncView decides which surface to show. Loading timing decides when to show it, so a fast operation never flashes a skeleton and a shown indicator never disappears the instant it appeared.
Every loading surface routes its show/hide through createVisibilityGate from @cngx/core/utils (source: projects/core/utils/visibility-gate.ts):
function createVisibilityGate(
isActive: Signal<boolean>,
delay: Signal<number>, // showDelay - suppress the flash on fast ops
minDwell: Signal<number>, // keep visible at least this long once shown
): Signal<boolean>;Two rules, one derived signal:
showDelay- the source must stay busy this long before the surface appears. An operation that finishes inside the delay never shows anything.minDwell- once shown, the surface stays for at least this long, so it never flickers out on a load that resolves a frame later.
The CNGX_LOADING_CONFIG cascade
The two timings, plus the spinner-vs-skeleton cutoff, come from one config token defaulted in @cngx/core/utils (source: projects/core/utils/loading-config.ts):
interface CngxLoadingConfig {
showDelay: number; // default 120
minDwell: number; // default 400
spinnerVsSkeletonCutoff: number; // default 800
}Standard cascade shape:
provideLoadingConfig(withShowDelay(200), withMinDwell(600))- app-wide, inbootstrapApplicationproviders.provideLoadingConfigAt(withShowDelay(0))- component scope, inviewProviders.injectLoadingConfig()- read the resolved config in an injection context.- Features:
withShowDelay(ms),withMinDwell(ms),withSpinnerVsSkeletonCutoff(ms).
Resolution priority for any one surface:
- Per-instance input (
[delay]/[minDwell]oncngx-loading-indicator/cngx-loading-overlay;[skeletonDelay]on the recycler). provideLoadingConfigAt(...)in a component'sviewProviders.provideLoadingConfig(...)at the app root.CNGX_LOADING_DEFAULTS(120 / 400 / 800).
Surfaces that gate through it: cngx-skeleton, cngx-loading-indicator, cngx-loading-overlay, the cngx-async-container skeleton view and refresh bar, and the recycler skeleton (injectRecycler).
Measured latency: createLatencyProbe
showDelay and minDwell shape a single load. Latency awareness looks one step further: it measures how long the previous busy window lasted, so an app-shell indicator can pick a spinner (last load was fast) or a skeleton (last load was slow) before the next load even renders.
createLatencyProbe (source: projects/core/utils/latency-probe.ts) measures the busy-envelope of a boolean-busy source:
const probe = createLatencyProbe(() => registry.isAnythingLoading());
// probe.lastDuration(): ms of the last completed busy window, or undefined
// probe.isBusy(): mirrors the source
const showSkeleton = computed(() => {
const last = probe.lastDuration();
return last !== undefined && last > injectLoadingConfig().spinnerVsSkeletonCutoff;
});The duration is a wall-clock sample taken at the busy edge through an injectable monotonic clock (default performance.now()), so it is a measurement side effect, not derived state. The probe writes lastDuration from an effect that tracks only the busy source, reads the clock in untracked(), and never reads its own output back. The Signal-First Internals chapter documents why this is a sanctioned write-in-effect.
In @cngx/common/data, injectLatencyProbe() bridges the app-wide CngxAsyncRegistry.isAnythingLoading signal into a probe, so the whole app's busy-envelope drives the choice. When no registry is provided it returns a probe that is simply never busy. The selection stays a two-line consumer @if: CNGX ships the primitive, the bridge, and the cutoff; the consumer composes the indicator.
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.CngxAsyncBoundary- provides the aggregate over its keyed source list, so a bridge nested in the boundary fires on the combined status with no[state]binding.
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.