Most real blocs eventually have to fetch something. BlaC has no special async primitive — an async action is just a method that awaits and emits as it goes. The work is in modelling the loading lifecycle as state so the UI can render it, and in guarding against races when requests overlap.
This page covers the canonical loading flow, derived loading flags, a reusable “loadable” surface, cancellation, and — importantly — what BlaC does not do with React Suspense.
The core pattern: an async method moves state through loading -> success/error, with a request-id guard so a stale response can never overwrite a newer one.
Model the lifecycle as a discriminated union keyed on status. This makes illegal states unrepresentable — you can’t have data and an error at the same time, and TypeScript narrows each branch for you in the view.
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<
type UserState = {
status:"idle";
} | {
status:"loading";
} | {
status:"success";
user:User;
} | {
status:"error";
message:string;
}
UserState> {
// Monotonic counter: every call gets a higher id than the last.
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String(
function(localvar)e: unknown
e) });
}
};
}
Why emit and not patch here? Because the union has no shared shape — loading has no user key, error has no user key. patch deep-merges, which would leave a stale user lingering under an error status. emit replaces wholesale, so each branch is exactly the keys that branch declares. (See Cubit for the replace-vs-merge rule.)
Two calls to load('a') then load('b') can resolve in either order — the network doesn’t promise FIFO. Without the guard, a slow 'a' response landing after 'b' would overwrite the correct result with stale data.
The guard is three lines:
Demo.load: (id:string)=>Promise<void>
load=async(
id: string
id:string)=> {
const
const reqId:number
reqId = ++this.
Demo.requestId: number
requestId; // claim the latest slot
// ... await the request ...
if (
const reqId:number
reqId!==this.
Demo.requestId: number
requestId) return; // a newer call has started; bail
// ... emit the result ...
};
++this.requestId claims a fresh id and records it as the current one. Any in-flight call whose reqId no longer equals this.requestId knows a newer call has superseded it and silently returns before emitting. This is cheaper than wiring an AbortController and is enough whenever you only care about the result of the newest request. When you also need to stop the actual network work, reach for cancellation.
Async state machine — live demo
Model idle → loading → success | error as a discriminated union. Click either Fetch button — the status badge tracks each transition in real time. Fire two requests in quick succession to see the request-id guard discard the slower one.
Because the state is a discriminated union, the consumer switches on status and TypeScript narrows each branch. Auto-tracking records the read of state.status (and whatever else each branch touches), so the component re-renders exactly when those paths change.
A union status is the source of truth, but views often want a boolean like isLoading or a “can I retry?” flag. Derive these with getters rather than storing them — a stored boolean has to be kept in sync with status by hand and will eventually drift.
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
When several blocs each carry one async resource, the same four-branch union repeats. Factor it into a generic Loadable<T> type and a couple of helpers so each bloc stays terse and every consumer narrows the same way.
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String(
function(localvar)e: unknown
e)));
}
};
}
When the resource is one field among several (a list page with filters, say), nest the loadable on a key instead of making it the whole state, and use patch to update that one field — patch deep-merges, so the surrounding fields survive:
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<
interface ListState
ListState> {
private
ListCubit.requestId: number
requestId=0;
constructor() {
super({
ListState.filter: string
filter: 'all',
ListState.items: Loadable<Item[]>
items: {
status: "idle"
status: 'idle' } });
}
ListCubit.load: ()=>Promise<void>
load=async()=> {
const
const reqId:number
reqId = ++this.
ListCubit.requestId: number
requestId;
// patch only the `items` field; `filter` is untouched.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so stateChanged
system events see the merged prev/next), and the registry-level
stateChanged notification. We still call
super.patch so path-marking semantics (the whole point of patch) are
preserved.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so stateChanged
system events see the merged prev/next), and the registry-level
stateChanged notification. We still call
super.patch so path-marking semantics (the whole point of patch) are
preserved.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so stateChanged
system events see the merged prev/next), and the registry-level
stateChanged notification. We still call
super.patch so path-marking semantics (the whole point of patch) are
preserved.
patch({
items?: {
status?: "idle"|undefined;
} | {
status?:"loading"|undefined;
} | {
status?:"error"|undefined;
message?: string |undefined;
} | {
status?:"success"|undefined;
data?: readonly {
id?: string |undefined;
}[] |undefined;
} |undefined
items: {
status?:"error"|undefined
status: 'error',
message?: string |undefined
message:
var String:StringConstructor
(value?:any)=> string
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
BlaC does not integrate with React Suspense, and useBloc does not work with the use() hook for data fetching. This is by design, and it’s worth being precise about why.
useBloc subscribes to the bloc’s path-scoped channel and re-renders through a useReducer dispatch — React’s ordinary update path. It does not use useSyncExternalStore, and it never throws a promise to a Suspense boundary or unwraps one via use(). There is no code path in useBloc that suspends a component. (Verified in useBloc; the hook reads state directly during render and forces an update on change.)
So you don’t get a <Suspense fallback={…}> boundary catching a BlaC load for free. Instead, model the loading state explicitly — which is exactly what the status union and the loadable surface above are for. The “fallback” is just the loading branch of your switch:
functionReportView() {
const [state,report] = useBloc(ReportCubit, {
onMount: (bloc) => bloc.load(),// kick off the fetch when the view appears
});
// This switch *is* your Suspense boundary, written out by hand.
if (state.status==='loading'|| state.status==='idle') {
// narrowed to success: `state.data` is the Report.
return<h1>{state.data.title}</h1>;
}
The upside of explicit modelling: error and retry are first-class states, not exceptions you have to catch at a boundary, and the loading UI lives next to the data it’s loading. The cost is the if ladder — but with a discriminated union it’s a few lines and fully type-checked.
The request-id guard stops a stale result from being applied, but the underlying request keeps running. When you also want to abort the in-flight network work — to save bandwidth, or because the bloc is being torn down — pair the guard with an AbortController.
Pass the controller’s signal to fetch, and abort the previous controller before starting a new request:
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<
type SearchState = {
status:"idle";
} | {
status:"loading";
} | {
status:"success";
results:Results;
} | {
status:"error";
message:string;
}
SearchState> {
private
SearchCubit.requestId: number
requestId=0;
private
SearchCubit.controller: AbortController |null
controller:
interface AbortController
The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.
The abort() method of the AbortController interface aborts an asynchronous operation before it has completed. This is able to abort fetch requests, the consumption of any response bodies, or streams.
The signal read-only property of the AbortController interface returns an AbortSignal object instance, which can be used to communicate with/abort an asynchronous operation as desired.
// An abort surfaces as an AbortError; it isn't a real failure.
if (
function(localvar)e: unknown
einstanceof
var DOMException: {
new(message?:string, name?:string):DOMException;
prototype:DOMException;
readonly INDEX_SIZE_ERR:1;
readonly DOMSTRING_SIZE_ERR:2;
readonly HIERARCHY_REQUEST_ERR:3;
readonly WRONG_DOCUMENT_ERR:4;
readonly INVALID_CHARACTER_ERR:5;
readonly NO_DATA_ALLOWED_ERR:6;
readonly NO_MODIFICATION_ALLOWED_ERR:7;
readonly NOT_FOUND_ERR:8;
readonly NOT_SUPPORTED_ERR:9;
readonly INUSE_ATTRIBUTE_ERR:10;
readonly INVALID_STATE_ERR:11;
readonly SYNTAX_ERR:12;
readonly INVALID_MODIFICATION_ERR:13;
readonly NAMESPACE_ERR:14;
readonly INVALID_ACCESS_ERR:15;
...9 more ...;
readonly DATA_CLONE_ERR:25;
}
The DOMException interface represents an abnormal event (called an exception) that occurs as a result of calling a method or accessing a property of a web API. This is how error conditions are described in web APIs.
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String(
function(localvar)e: unknown
e) });
}
};
}
Two guards now run together: AbortController stops the request (and makes fetch reject with an AbortError you ignore), while the requestId check still protects the emit in case a response was already in transit when the new call started.
A request in flight when the bloc is disposed will try to emit on a dead container, which throws. Wire onSystemEvent('dispose', …) to abort the controller so the request unwinds cleanly:
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<
type SearchState = {
status:"idle"|"loading";
}
SearchState> {
private
SearchCubit.controller: AbortController |null
controller:
interface AbortController
The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.
The abort() method of the AbortController interface aborts an asynchronous operation before it has completed. This is able to abort fetch requests, the consumption of any response bodies, or streams.
onSystemEvent('dispose', …) fires when the instance is being torn down (its ref count hit zero, or it was cleared). Aborting there cancels the request, and the AbortError branch swallows the rejection — no emit reaches the disposed container. See System Events for the full lifecycle.