Blex for Blazor

Core concepts

Actions

An action is a named, batched, recorded unit of change. Everything that mutates state becomes one - even a direct property assignment.

The convention

Mark a method named OnXxx with [BlexAction]. The generator emits a public Xxx(...) wrapper that runs your method inside a dispatch: all the mutations it makes are folded into one notification and one time-travel entry.

C#
[BlexAction]
private void OnIncrement() => Count += Step;

[BlexAction]
private void OnReset()
{
    Count = 0;   // both assignments are folded into
    Step = 1;    // a single "Reset" action and one render
}
Generated
public void Increment()
    => Dispatch("Increment", () => OnIncrement());

public void Reset()
    => Dispatch("Reset", () => OnReset());

Naming

The wrapper name comes from the method name with the On prefix stripped, or from [BlexAction(Name = "…")]. The Name is used in two different ways depending on whether it is a valid C# identifier:

DeclarationMethod you callLabel in DevTools / history
[BlexAction] void OnIncrement()Increment()Increment
[BlexAction(Name = "Bump")] void OnIncrement()Bump()Bump
[BlexAction(Name = "Apply preset")] void OnApplyPreset()ApplyPreset()Apply preset

In other words: a name containing spaces (or anything else illegal in an identifier, or a C# keyword) is treated purely as a display label, and the method name still comes from the On prefix. A method that neither starts with On nor supplies a usable name is BLEX002.

Arguments become the payload

Action parameters are captured as name/value pairs and travel with the action to middleware, subscribers, the test harness and the DevTools monitor. Nothing to declare.

C#
[BlexAction]
private void OnSetStep(int step) => Step = step;

[BlexAction]
private void OnAddItem(string name, int quantity = 1) => /* ... */;

// store.SetStep(5) is recorded as:
//   ActionName   "SetStep"
//   Args         [ { Name = "step", Value = 5 } ]
//   DevTools     { "step": 5 }

Capture is skipped entirely when nothing is observing - the generated wrapper checks IsObserved first, so an app with no middleware, no DevTools and no subscribers pays nothing for the payload.

Parameter rules:

  • Default values and params are preserved on the wrapper.
  • ref, out and in parameters are rejected - the wrapper has to capture them by value (BLEX013).
  • Generic action methods are rejected; the wrapper cannot carry type parameters (BLEX014).

Batching

This is the point of actions. Assigning three fields directly produces three actions and three renders; doing it inside one action produces one of each.

Live demo Three ways to set the same two fields

Watch the notification counter - and the Inspector's action feed - after each button.

Count 0
Step 1
Notifications from last click 0
Actions from last click 0

The first button dispatches Set Count and Set Step separately. The other two dispatch exactly one action and render once.

Direct assignment: the "Set X" action

Generated properties have public setters, and assigning one from outside any action is still a first-class action named Set X, carrying the assigned value as its payload. It is vetoable by middleware, recorded for undo/redo and visible in DevTools - so nothing can change state behind the pipeline's back.

C#
store.Count = 5;

// Recorded as:
//   ActionName   "Set Count"
//   Args         [ { Name = "Count", Value = 5 } ]

Batch: ad-hoc grouping

When the grouping is a caller's concern rather than the store's, Batch wraps any set of mutations into one named action - Pinia's $patch or MobX's runInAction.

C#
// One notification, one history entry, one middleware pass.
store.Batch("Apply preset", () =>
{
    store.Count = 10;
    store.Step = 5;
});

ResetState

Every store snapshots itself the first time it is registered with the manager. ResetState() restores that snapshot as a normal ResetState action: vetoable, observable, persisted and undoable. It is a no-op on a store that was never registered, and equally a no-op on a store that is already holding its initial state - resetting an untouched form notifies nobody, writes nothing and adds no undo step, the same rule every other mutation in Blex follows.

C#
store.ResetState();   // back to the state captured at first registration

BlexManager.ResetAll() does the same across every registered store - the usual sign-out move. Each store resets as its own action, so middleware can exempt individual stores and DevTools shows one entry per store rather than one opaque global wipe.

Application-wide reset
@inject BlexManager Manager

// Sign-out: every store back to its registered state, each as its own action.
private void SignOut()
{
    _auth.Clear();
    Manager.ResetAll();
}

// Keep one store out of it with a filter veto:
builder.Services.AddBitBlexServices(options => options.UseFilter(
    ctx => !(ctx.Store.Name == "theme" && ctx.ActionName == "ResetState")));

Async actions

An [BlexAction] may return Task or ValueTask. State changes made before and after an await update the UI as they happen, but the whole operation is recorded as a single action.

C#
[BlexAction]
private async Task OnSaveDraft(string text)
{
    IsSaving = true;              // renders immediately
    await _api.SaveAsync(text);
    IsSaving = false;             // renders again
    SavedAt = DateTimeOffset.Now;
}
// Recorded once, as "SaveDraft".

If what you are writing is a side-effect with a loading and error lifecycle, reach for [BlexEffect] instead - it generates all of that for you.

A store is owned by its in-flight async action

Dispatch is single-threaded, matching Blazor's rendering model. While an async action is awaiting, its store's record depth stays raised - so a mutation made from elsewhere during that window is attributed to the in-flight action instead of being recorded as its own. Treat a store as owned by its running async action until it completes.

Never async void

An async void action cannot be awaited or recorded correctly, so it is a hard error (BLEX010). Return Task or ValueTask. Similarly, an action that returns a value gets warning BLEX011, because the generated wrapper discards it - put the result in a [BlexState] field instead.

What a dispatch actually does

  1. Runs every middleware's BeforeAction. If one calls Cancel(), the action stops here - nothing mutates.
  2. Raises the record and notify depth, then runs your method body. Nested actions fold into the outermost one.
  3. On completion, invalidates computed values and raises StateChanged once.
  4. Records the action: middleware OnAction hooks run, then DevTools receives it, then subscribers.

The global state snapshot attached to an action is captured lazily, on first access. Observers that never read it - persistence, for instance - never pay to serialize the whole tree. When nothing at all is observing, the recording step is skipped outright.

An unhandled error has occurred. Reload 🗙