Skip to content

Async

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.

type
type UserState = {
status: "idle";
} | {
status: "loading";
} | {
status: "success";
user: User;
} | {
status: "error";
message: string;
}
UserState
=
| {
status: "idle"
status
: 'idle' }
| {
status: "loading"
status
: 'loading' }
| {
status: "success"
status
: 'success';
user: User
user
:
interface User
User
}
| {
status: "error"
status
: 'error';
message: string
message
: string };
class
class UserCubit
UserCubit
extends
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

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.
private
UserCubit.requestId: number
requestId
= 0;
constructor() {
super({
status: "idle"
status
: 'idle' });
}
UserCubit.load: (id: string) => Promise<void>
load
= async (
id: string
id
: string) => {
const
const reqId: number
reqId
= ++this.
UserCubit.requestId: number
requestId
;
this.
StateContainer<UserState, void, Record<string, never>>.emit(next: UserState): void
emit
({
status: "loading"
status
: 'loading' });
try {
const
const user: User
user
= await
const api: {
fetchUser(id: string): Promise<User>;
}
api
.
function fetchUser(id: string): Promise<User>
fetchUser
(
id: string
id
);
// Latest-wins: a slower earlier request must not clobber a newer one.
if (
const reqId: number
reqId
!== this.
UserCubit.requestId: number
requestId
) return;
this.
StateContainer<UserState, void, Record<string, never>>.emit(next: UserState): void
emit
({
status: "success"
status
: 'success',
user: User
user
});
} catch (
function (local var) e: unknown
e
) {
if (
const reqId: number
reqId
!== this.
UserCubit.requestId: number
requestId
) return;
this.
StateContainer<UserState, void, Record<string, never>>.emit(next: UserState): void
emit
({
status: "error"
status
: 'error',
message: string
message
:
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(
function (local var) 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.

AsyncDemoCubitidle

state.status === 'idle' — ready to fetch

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.

function UserCard({ id }: { id: string }) {
const [state, user] = useBloc(UserCubit);
switch (state.status) {
case 'idle':
return <button onClick={() => user.load(id)}>Load user</button>;
case 'loading':
return <p>Loading…</p>;
case 'error':
// `state` is narrowed: `state.message` is available here.
return <p>Failed: {state.message}</p>;
case 'success':
// narrowed to the success branch: `state.user` exists.
return <p>{state.user.name}</p>;
}
}

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.

class
class UserCubit
UserCubit
extends
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

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
> {
constructor() {
super({
status: "idle"
status
: 'idle' });
}
get
UserCubit.isLoading: boolean
isLoading
() {
return this.
StructuralContainer<UserState>.state: UserState
state
.
status: "idle" | "loading" | "success" | "error"
status
=== 'loading';
}
get
UserCubit.canRetry: boolean
canRetry
() {
return this.
StructuralContainer<UserState>.state: UserState
state
.
status: "idle" | "loading" | "success" | "error"
status
=== 'idle' || this.
StructuralContainer<UserState>.state: {
status: "loading";
} | {
status: "success";
user: User;
} | {
status: "error";
message: string;
}
state
.
status: "loading" | "success" | "error"
status
=== 'error';
}
get
UserCubit.user: User | null
user
() {
return this.
StructuralContainer<UserState>.state: UserState
state
.
status: "idle" | "loading" | "success" | "error"
status
=== 'success' ? this.
StructuralContainer<UserState>.state: {
status: "success";
user: User;
}
state
.
user: User
user
: null;
}
}

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.

export type
type Loadable<T> = {
status: "idle";
} | {
status: "loading";
} | {
status: "success";
data: T;
} | {
status: "error";
message: string;
}
Loadable
<
function (type parameter) T in type Loadable<T>
T
> =
| {
status: "idle"
status
: 'idle' }
| {
status: "loading"
status
: 'loading' }
| {
status: "success"
status
: 'success';
data: T
data
:
function (type parameter) T in type Loadable<T>
T
}
| {
status: "error"
status
: 'error';
message: string
message
: string };
export const
const loading: () => Loadable<never>
loading
= ():
type Loadable<T> = {
status: "idle";
} | {
status: "loading";
} | {
status: "success";
data: T;
} | {
status: "error";
message: string;
}
Loadable
<never> => ({
status: "loading"
status
: 'loading' });
export const
const success: <T>(data: T) => Loadable<T>
success
= <
function (type parameter) T in <T>(data: T): Loadable<T>
T
>(
data: T
data
:
function (type parameter) T in <T>(data: T): Loadable<T>
T
):
type Loadable<T> = {
status: "idle";
} | {
status: "loading";
} | {
status: "success";
data: T;
} | {
status: "error";
message: string;
}
Loadable
<
function (type parameter) T in <T>(data: T): Loadable<T>
T
> => ({
status: "success"
status
: 'success',
data: T
data
,
});
export const
const failure: (message: string) => Loadable<never>
failure
= (
message: string
message
: string):
type Loadable<T> = {
status: "idle";
} | {
status: "loading";
} | {
status: "success";
data: T;
} | {
status: "error";
message: string;
}
Loadable
<never> => ({
status: "error"
status
: 'error',
message: string
message
,
});

A bloc whose entire state is one loadable resource can extend Cubit<Loadable<T>> directly:

import {
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

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
} from '@blac/core';
class
class ReportCubit
ReportCubit
extends
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

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 Loadable<T> = {
status: "idle";
} | {
status: "loading";
} | {
status: "success";
data: T;
} | {
status: "error";
message: string;
}
Loadable
<
interface Report
Report
>> {
private
ReportCubit.requestId: number
requestId
= 0;
constructor() {
super({
status: "idle"
status
: 'idle' });
}
ReportCubit.load: () => Promise<void>
load
= async () => {
const
const reqId: number
reqId
= ++this.
ReportCubit.requestId: number
requestId
;
this.
StateContainer<Loadable<Report>, void, Record<string, never>>.emit(next: Loadable<Report>): void
emit
(
const loading: () => Loadable<never>
loading
());
try {
const
const data: Report
data
= await
const api: {
fetchReport(): Promise<Report>;
}
api
.
function fetchReport(): Promise<Report>
fetchReport
();
if (
const reqId: number
reqId
!== this.
ReportCubit.requestId: number
requestId
) return;
this.
StateContainer<Loadable<Report>, void, Record<string, never>>.emit(next: Loadable<Report>): void
emit
(
const success: <Report>(data: Report) => Loadable<Report>
success
(
const data: Report
data
));
} catch (
function (local var) e: unknown
e
) {
if (
const reqId: number
reqId
!== this.
ReportCubit.requestId: number
requestId
) return;
this.
StateContainer<Loadable<Report>, void, Record<string, never>>.emit(next: Loadable<Report>): void
emit
(
const failure: (message: string) => Loadable<never>
failure
(
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(
function (local var) 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:

import {
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

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
} from '@blac/core';
interface
interface ListState
ListState
{
ListState.filter: string
filter
: string;
ListState.items: Loadable<Item[]>
items
:
type Loadable<T> = {
status: "idle";
} | {
status: "loading";
} | {
status: "success";
data: T;
} | {
status: "error";
message: string;
}
Loadable
<
interface Item
Item
[]>;
}
class
class ListCubit
ListCubit
extends
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

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.
this.
StateContainer<ListState, void, Record<string, never>>.patch(partial: {
filter?: string | undefined;
items?: {
status?: "idle" | undefined;
} | {
status?: "loading" | undefined;
} | {
status?: "error" | undefined;
message?: string | undefined;
} | {
status?: "success" | undefined;
data?: readonly {
id?: string | undefined;
}[] | undefined;
} | undefined;
}): void

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
:
const loading: () => Loadable<never>
loading
() });
try {
const
const data: Item[]
data
= await
const api: {
fetchItems(filter: string): Promise<Item[]>;
}
api
.
function fetchItems(filter: string): Promise<Item[]>
fetchItems
(this.
StructuralContainer<ListState>.state: ListState
state
.
ListState.filter: string
filter
);
if (
const reqId: number
reqId
!== this.
ListCubit.requestId: number
requestId
) return;
this.
StateContainer<ListState, void, Record<string, never>>.patch(partial: {
filter?: string | undefined;
items?: {
status?: "idle" | undefined;
} | {
status?: "loading" | undefined;
} | {
status?: "error" | undefined;
message?: string | undefined;
} | {
status?: "success" | undefined;
data?: readonly {
id?: string | undefined;
}[] | undefined;
} | undefined;
}): void

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
:
const success: <Item[]>(data: Item[]) => Loadable<Item[]>
success
(
const data: Item[]
data
) });
} catch (
function (local var) e: unknown
e
) {
if (
const reqId: number
reqId
!== this.
ListCubit.requestId: number
requestId
) return;
this.
StateContainer<ListState, void, Record<string, never>>.patch(partial: {
filter?: string | undefined;
items?: {
status?: "idle" | undefined;
} | {
status?: "loading" | undefined;
} | {
status?: "error" | undefined;
message?: string | undefined;
} | {
status?: "success" | undefined;
data?: readonly {
id?: string | undefined;
}[] | undefined;
} | undefined;
}): void

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.

String
(
function (local var) e: unknown
e
) } });
}
};
}

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:

function ReportView() {
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') {
return <p>Loading…</p>;
}
if (state.status === 'error') {
return (
<div role="alert">
{state.message} <button onClick={report.load}>Retry</button>
</div>
);
}
// 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:

class
class SearchCubit
SearchCubit
extends
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

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.

MDN Reference

AbortController
| null = null;
constructor() {
super({
status: "idle"
status
: 'idle' });
}
SearchCubit.search: (query: string) => Promise<void>
search
= async (
query: string
query
: string) => {
const
const reqId: number
reqId
= ++this.
SearchCubit.requestId: number
requestId
;
// Abort the previous in-flight request, if any.
this.
SearchCubit.controller: AbortController | null
controller
?.
AbortController.abort(reason?: any): void

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.

MDN Reference

abort
();
const
const controller: AbortController
controller
= new
var AbortController: new () => AbortController

The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.

MDN Reference

AbortController
();
this.
SearchCubit.controller: AbortController | null
controller
=
const controller: AbortController
controller
;
this.
StateContainer<SearchState, void, Record<string, never>>.emit(next: SearchState): void
emit
({
status: "loading"
status
: 'loading' });
try {
const
const res: Response
res
= await
function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>
fetch
(`/api/search?q=${
query: string
query
}`, {
RequestInit.signal?: AbortSignal | null | undefined

An AbortSignal to set request's signal.

signal
:
const controller: AbortController
controller
.
AbortController.signal: AbortSignal

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.

MDN Reference

signal
,
});
const
const results: Results
results
= (await
const res: Response
res
.
Body.json(): Promise<any>
json
()) as
interface Results
Results
;
if (
const reqId: number
reqId
!== this.
SearchCubit.requestId: number
requestId
) return;
this.
StateContainer<SearchState, void, Record<string, never>>.emit(next: SearchState): void
emit
({
status: "success"
status
: 'success',
results: Results
results
});
} catch (
function (local var) e: unknown
e
) {
// An abort surfaces as an AbortError; it isn't a real failure.
if (
function (local var) e: unknown
e
instanceof
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.

MDN Reference

DOMException
&&
function (local var) e: DOMException
e
.
DOMException.name: string

The name read-only property of the DOMException interface returns a string that contains one of the strings associated with an error name.

MDN Reference

name
=== 'AbortError') return;
if (
const reqId: number
reqId
!== this.
SearchCubit.requestId: number
requestId
) return;
this.
StateContainer<SearchState, void, Record<string, never>>.emit(next: SearchState): void
emit
({
status: "error"
status
: 'error',
message: string
message
:
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(
function (local var) 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:

class
class SearchCubit
SearchCubit
extends
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

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.

MDN Reference

AbortController
| null = null;
constructor() {
super({
status: "idle" | "loading"
status
: 'idle' });
this.
StateContainer<SearchState, void, Record<string, never>>.onSystemEvent: <"dispose">(event: "dispose", handler: SystemEventHandler<SearchState, "dispose">) => (() => void)
onSystemEvent
('dispose', () => {
this.
SearchCubit.controller: AbortController | null
controller
?.
AbortController.abort(reason?: any): void

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.

MDN Reference

abort
();
});
}
}

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.

  • Patterns & Recipes — the request-id recipe in context, plus hydration-aware loading
  • Cubitemit / patch / update, getters, and the init hook
  • useBloconMount, select, and why there’s no useSyncExternalStore
  • System Events — the dispose and hydrationChanged lifecycle hooks
  • Best Practices — why an explicit status union beats scattered booleans