Blex for Blazor

Reacting to state

Cross-store coordination

Let one store react to another's actions without either of them knowing the other exists.

BlexManager

The manager aggregates every registered store into one global state tree, runs the middleware pipeline, feeds the DevTools bridge and applies time-travel snapshots. Most application code never touches it - you inject stores. You reach for the manager when you need coordination, a global snapshot, or dynamic registration.

Registry

Member Description
IReadOnlyList<IBlexStore> Stores Every registered store.
TStore? GetStore<TStore>() The first registered store of that type, or null.
void Register(IBlexStore) Adds a store. Idempotent. Captures its initial state so ResetState() has somewhere to return to.
void Unregister(IBlexStore) Removes a store; it keeps working, but its actions are no longer observed.
void ResetAll() Resets every registered store to its registered state, each as its own normal ResetState action. Stores already holding that state are skipped; one that fails is reported and skipped, and the rest still reset.

Observation

Member Description
event Action<BlexActionContext>? ActionDispatched Raised after every action, once the per-store event has fired. Prefer Subscribe, which isolates handler exceptions.
IDisposable Subscribe(Action<BlexActionContext>, Func<…, bool>? filter) Observe actions, optionally filtered. Handler exceptions are caught and reported.
IDisposable SubscribeTo<TStore>(Action<…>) Only actions from that store type.
IDisposable SubscribeToAction(string, Action<…>) Matches the bare name ("Increment") or the qualified name ("counter/Increment").
IDisposable SubscribeAsync(Func<…, Task>, Func<…, bool>? filter) An async reactor. The task is observed; failures are reported rather than left unobserved.
IDisposable Subscribe&lt;T&gt;(Func&lt;T&gt; selector, Action&lt;T&gt;, IEqualityComparer&lt;T&gt;?, bool fireImmediately) Fires only when a projection spanning stores changes. An Action<T, T> overload delivers (previous, current). Re-evaluated after restores too. See Subscriptions.
BlexSelector&lt;T&gt; CreateSelector&lt;T&gt;(Func&lt;T&gt;) A lazy, memoized derived value over the whole tree - [BlexComputed] lifted to the manager. Read .Value; it recomputes at most once per action, and only when something reads it. Dispose to detach.
event Action&lt;BlexActionErrorContext&gt;? ActionFailed Raised when an action body throws. The exception still propagates. See Middleware.

Global state

Member Description
JsonObject CaptureGlobalState() Builds the whole tree: { "storeName": { …state… }, … }.
void RestoreGlobalState(JsonObject) Applies a tree to every store without recording actions. A slice that fails to deserialize is reported and skipped; the other stores still restore.
event Action? StateRestored Raised after a restore. Persistence uses it to write the restored state back so a reload does not resurrect the pre-restore state.
Func&lt;JsonObject, JsonObject&gt;? DevToolsStateSanitizer Applied to the tree before it reaches DevTools. See DevTools.
Func&lt;string, string&gt;? DevToolsActionSanitizer Applied to the action label before it reaches DevTools.
Action&lt;BlexError&gt;? OnError Sink for isolated pipeline failures. See Error isolation.

Subscribing to actions

Subscribe and its filtered variants are the building block for coordination: react to one store's action and drive another. Handler exceptions are isolated and routed to OnError, so a misbehaving reactor cannot break dispatch for everyone else.

C#
// Everything from one store type
manager.SubscribeTo<CounterStore>(ctx => Console.WriteLine(ctx.QualifiedName));

// One action, by bare or qualified name
manager.SubscribeToAction("Increment", ctx => _analytics.Track("increment"));
manager.SubscribeToAction("counter/Increment", ctx => { });

// Anything, with your own filter
manager.Subscribe(
    ctx => _log.Add(ctx.QualifiedName),
    filter: ctx => ctx.Args.Count > 0);

// Dispose the returned token to stop observing.
Live demo Orders notify, without knowing about notifications

Placing an order dispatches only orders/Place. A manager subscription - registered by this page - turns it into a notification.

orders 0 orders · 0 units

No orders yet.

notifications 0 unread

Nothing yet.

The Inspector's feed shows both actions: orders/Place first, then notifications/Push dispatched by the reactor.

The action context

Every observer receives a BlexActionContext describing what just happened.

BlexActionContext

Member Description
IBlexStore Store The store that produced the action.
string ActionName The bare name, e.g. "Increment" or "Set Count".
string QualifiedName "storeName/ActionName".
IReadOnlyList&lt;BlexActionArg&gt; Args The captured parameter name/value pairs. Empty for parameterless actions; a Set X carries the assigned value.
int Sequence Monotonic action number.
DateTimeOffset Timestamp When the action completed, in UTC.
JsonObject GlobalState The whole state tree immediately after the action. Captured lazily on first access.

Read GlobalState synchronously

GlobalState is captured lazily on first access, because serializing every store is expensive and most observers never need it. Read it inside your handler: reading it later - after further actions have run - would capture their state instead. Treat the returned tree as read-only; every observer of that action shares the instance.

Async reactors

SubscribeAsync lets a reactor start async work - typically an effect on another store. The returned task is observed, so a failure is reported through OnError rather than surfacing as an unobserved exception.

C#
manager.SubscribeAsync(
    async ctx => await profileStore.Reload(),
    filter: ctx => ctx.QualifiedName == "auth/SignIn");

Watch for reaction loops

A reactor that dispatches an action which its own filter matches will recurse until the stack gives out. Keep reactors one-directional, or guard them with a flag. Blex freezes the in-flight snapshots of actions still being observed before a reactor's follow-up action mutates state, so attribution stays correct - but it will not stop a genuine loop.

Derived values that span stores

A subscription answers "tell me when this changed". The other half of the problem is "give me this value, cheaply, from several places at once" - and that is what [BlexComputed] does inside one store. CreateSelector lifts it to the manager: a lazy, memoized projection over the whole tree that recomputes at most once per action, however many readers ask for it.

C#
// Created once, read from anywhere.
var summary = manager.CreateSelector(
    () => $"{orders.Units} unit(s) · {notifications.Unread} message(s)");

summary.Value;   // computes
summary.Value;   // cached
orders.Place("Widget", 2);
summary.Value;   // recomputes, once, for every reader

summary.Dispose();   // detaches from the manager

// In a component, let the base class own it:
protected override void OnInitialized()
{
    _summary = Manager.CreateSelector(() => Orders.Units + Cart.Lines.Count);
    OwnsSubscription(_summary);
}
Live demo One computation, three readers

Every card below reads the same selector. Place an order and watch the computation counter move once, not three times - and not at all until something reads it.

Reader A 0 unit(s) · 0 message(s)

Reader B 0 unit(s) · 0 message(s)

Reader C 0 unit(s) · 0 message(s)

Projection ran 1 time(s)
Renders of this page 0

Subscribe pushes, CreateSelector pulls

Subscribe(selector, …) evaluates eagerly after every action so it can tell one observer that the value moved - use it when something must happen. CreateSelector evaluates lazily and shares the result - use it when something must be read. An action whose result nobody reads costs a flag write. Dispose the selector (or hand it to OwnsSubscription) to detach it.

Where to put the wiring

Three practical options, in rough order of preference:

  • A component that owns the lifetime - OwnsSubscription(...) in OnInitialized. Good when the coordination only matters while some part of the UI is alive. This page does exactly that.
  • Middleware - register an IBlexMiddleware that reacts in OnAction. Good for app-wide, always-on rules. See Middleware.
  • A coordinator service - a scoped service that subscribes in its constructor and disposes its tokens. Resolve it once at startup so it exists.

Dynamic registration

Stores register themselves when first resolved from DI, and <BlexProvider> registers everything up front. For a lazily-loaded feature you can register and unregister by hand. An unregistered store keeps working standalone - its actions simply stop being observed.

C#
var featureStore = new FeatureStore();
manager.Register(featureStore);      // now observed: middleware, DevTools, history, persistence

// ... when the feature is torn down
manager.Unregister(featureStore);    // still usable, just no longer observed

Single-threaded by design

The manager is built for single-threaded dispatch, matching Blazor's rendering model. Its internal lock protects the store registry and global-state capture only; the pipeline itself - the sequence counter, middleware, DevTools - assumes actions are not dispatched concurrently. If you dispatch from several threads, coordinate externally.

An unhandled error has occurred. Reload 🗙