Core concepts
Effects
Async work with a loading flag, an error slot, a cancellation method and a concurrency policy - all generated.
The convention
Mark an async method named OnXxx returning Task or
ValueTask with [BlexEffect]. You get a public Xxx(...)
wrapper plus reactive lifecycle properties. The body is still recorded as one named,
time-travelable action.
[BlexEffect]
private async Task OnLoadUser(int id)
{
User = await _api.GetUserAsync(id);
}
// Generated:
// Task LoadUser(int id)
// bool LoadUserIsLoading
// Exception? LoadUserErrorGenerated for [BlexEffect] OnLoadUser(int id, CancellationToken ct)
| Member | Description |
|---|---|
Task LoadUser(int id) |
The public wrapper. Note that the CancellationToken parameter is not on it - the generator supplies the token. |
bool LoadUserIsLoading |
True while any run is in flight. Reference-counted across overlapping runs. |
Exception? LoadUserError |
The exception from the last run, or null. Cleared at the start of each run. |
void CancelLoadUser() |
Cancels the in-flight run (the most recently started one when runs overlap). Emitted only when the effect takes a CancellationToken. |
The wrapper never throws: an exception from the body is captured into XxxError
instead of propagating. That is deliberate - an effect is usually fired from an event handler
where nothing would catch it.
Loading is reference-counted
XxxIsLoading stays true until the last in-flight run
finishes, so overlapping invocations do not flip it off early. It is set through
SetEffectState, which notifies the UI but never records a time-travel action -
a spinner turning on is not something you want to rewind to.
Cancellation
Give the effect a trailing CancellationToken parameter and the
generator supplies the token itself - it does not appear on the public wrapper - and emits a
CancelXxx() method.
[BlexEffect(Concurrency = BlexEffectConcurrency.Latest)]
private async Task OnSearch(string query, CancellationToken ct)
{
Results = await _api.SearchAsync(query, ct);
}
// Generated:
// Task Search(string query) <- no token on the public signature
// void CancelSearch()
// bool SearchIsLoading
// Exception? SearchErrorWhose cancellation was it?
Cancellation through the effect's own token - from CancelXxx() or
from being superseded in Latest mode - is a normal outcome and never
populates Error. A foreign OperationCanceledException,
such as an HttpClient timeout, or any cancellation in an effect that has no
token parameter, is a real failure and is recorded in Error.
Disposing a store cancels its effects
A store is IDisposable, and its container disposes it - a Blazor Server
circuit ending, a test scope closing. That cancels every in-flight effect and stops any
pending debounce timer, so a request started moments before teardown does not carry on
resuming against a dead renderer. It counts as the effect's own cancellation, so it
populates no Error; the store itself stays usable, because disposal ordering
must not be able to strand a final persistence flush.
Concurrency
[BlexEffect(Concurrency = …)] decides what happens when a run starts while another
is still going, mirroring the RxJS flattening operators NgRx effects are built on.
| Mode | RxJS | Semantics | Use it for |
|---|---|---|---|
Parallel default | mergeMap | Every run proceeds concurrently. | Independent fetches. |
Latest | switchMap | A new run cancels the previous one. | Type-ahead search. |
Drop | exhaustMap | New runs are ignored while one is in flight. | Double-click-proof submits. |
Queue | concatMap | Runs execute one at a time, in arrival order. | Ordered writes. |
Each button starts the same 1.2 s job. Click one several times quickly and read the timeline.
Timeline is empty. Try clicking Latest three times in a row, then Queue three times, and compare.
What you should see
- Parallel - three clicks give three
startedlines immediately and threefinishedlines ~1.2 s later. - Latest - each click
cancelleds the previous run; only the last one finishes. - Drop - the second and third clicks produce
dropped; the first run finishes undisturbed. - Queue - the runs start ~1.2 s apart, strictly in the order you clicked.
A realistic type-ahead
Latest plus a token is the whole recipe for search-as-you-type: an obsolete
request cannot land after a newer one and overwrite the results.
Type quickly: each keystroke supersedes the previous request. Flip the failure switch to see the generated error slot fill in.
Debouncing
Latest stops a stale response from landing, but it still sends a request per
keystroke. DebounceMilliseconds waits for a quiet period first, so only the
invocation the user actually paused on runs at all. The two compose, and together they are
the complete type-ahead recipe: debounce decides whether to ask, Latest
decides which answer is allowed to land.
[BlexEffect(DebounceMilliseconds = 300, Concurrency = BlexEffectConcurrency.Latest)]
private async Task OnTypeAhead(string query, CancellationToken ct)
{
Query = query;
Results = await _api.SearchAsync(query, ct);
}
The wait happens before anything observable, which is what makes it worth generating rather
than hand-rolling. A superseded invocation never flips XxxIsLoading, never
clears XxxError, is never offered to middleware and records no action - so a
spinner bound to IsLoading does not blink once per keystroke, and DevTools does
not fill up with searches that never happened.
Type a word at normal speed. The keystroke counter climbs per character; the request counter climbs once, when you pause.
The gate is per effect, not per argument
Every invocation of the same effect shares one gate, whatever its arguments. That is what you want for a search box; if you need one gate per key (say, per row in a grid), debounce at the call site instead.
Rules
-
The return type must be non-generic
TaskorValueTask(BLEX007). An effect owns the loading and error lifecycle, so there is nowhere to put a return value - write results into[BlexState]fields. Latestwithout aCancellationTokenparameter is warningBLEX009: supersession still marks the run stale, but the superseded body keeps executing because nothing can observe the cancellation.- Effects cannot be
static(BLEX008) or generic (BLEX014), and cannot take by-ref parameters (BLEX013).
A Queue effect must never invoke itself
Queue makes each run wait for its predecessor. If a queued effect invokes
itself - directly, or through a subscriber its body triggers - the nested call waits on a
gate that only the outer call can open, and both deadlock.
Latest cancels on invocation, not on approval
In Latest mode the previous run is cancelled the moment a new invocation
starts - even if middleware later vetoes that new action. A veto stops the mutation; it
does not resurrect the run it superseded.
The generated wrapper
Roughly what the generator emits for a Latest effect with a token:
private int __SearchPending;
public bool SearchIsLoading => __SearchPending > 0;
private Exception? __SearchError;
public Exception? SearchError => __SearchError;
private CancellationTokenSource? __SearchCts;
public void CancelSearch() => __SearchCts?.Cancel();
private int __SearchVersion;
public async Task Search(string query)
{
var version = ++__SearchVersion; // stale runs must not clobber newer error state
__SearchCts?.Cancel(); // Latest: supersede the previous run
var cts = new CancellationTokenSource();
__SearchCts = cts;
var ct = cts.Token;
try
{
BeginEffect(ref __SearchPending);
SetEffectState(ref __SearchError, null);
await DispatchAsync("Search", () => OnSearch(query, ct),
IsObserved ? [new BlexActionArg("query", query)] : null);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Our own cancellation: a normal outcome, not an error.
}
catch (Exception ex)
{
if (version == __SearchVersion)
SetEffectState(ref __SearchError, ex);
}
finally
{
if (ReferenceEquals(__SearchCts, cts))
__SearchCts = null;
cts.Dispose();
EndEffect(ref __SearchPending);
}
}