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.
[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
}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:
| Declaration | Method you call | Label 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.
[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
paramsare preserved on the wrapper. ref,outandinparameters 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.
Watch the notification counter - and the Inspector's action feed - after each button.
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.
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.
// 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.
store.ResetState(); // back to the state captured at first registrationBlexManager.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.
@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.
[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
- Runs every middleware's
BeforeAction. If one callsCancel(), the action stops here - nothing mutates. - Raises the record and notify depth, then runs your method body. Nested actions fold into the outermost one.
- On completion, invalidates computed values and raises
StateChangedonce. - Records the action: middleware
OnActionhooks 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.