Blex for Blazor

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.

C#
[BlexEffect]
private async Task OnLoadUser(int id)
{
    User = await _api.GetUserAsync(id);
}

// Generated:
//   Task       LoadUser(int id)
//   bool       LoadUserIsLoading
//   Exception? LoadUserError

Generated 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.

C#
[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? SearchError

Whose 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.

ModeRxJSSemanticsUse it for
Parallel defaultmergeMapEvery run proceeds concurrently.Independent fetches.
LatestswitchMapA new run cancels the previous one.Type-ahead search.
DropexhaustMapNew runs are ignored while one is in flight.Double-click-proof submits.
QueueconcatMapRuns execute one at a time, in arrival order.Ordered writes.
Live demo The four modes, side by side

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 started lines immediately and three finished lines ~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.

Live demo Search with Latest concurrency

Type quickly: each keystroke supersedes the previous request. Flip the failure switch to see the generated error slot fill in.

SearchIsLoading = false

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.

The whole recipe
[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.

Live demo Keystrokes in, requests out

Type a word at normal speed. The keystroke counter climbs per character; the request counter climbs once, when you pause.

Keystrokes 0
Requests started 0
TypeAheadIsLoading false

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 Task or ValueTask (BLEX007). An effect owns the loading and error lifecycle, so there is nowhere to put a return value - write results into [BlexState] fields.
  • Latest without a CancellationToken parameter is warning BLEX009: 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:

Generated (simplified)
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);
    }
}
An unhandled error has occurred. Reload 🗙