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<T>(Func<T> selector, Action<T>, IEqualityComparer<T>?, 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<T> CreateSelector<T>(Func<T>) |
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<BlexActionErrorContext>? 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<JsonObject, JsonObject>? DevToolsStateSanitizer |
Applied to the tree before it reaches DevTools. See DevTools. |
Func<string, string>? DevToolsActionSanitizer |
Applied to the action label before it reaches DevTools. |
Action<BlexError>? 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.
// 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.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<BlexActionArg> 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.
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.
// 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);
}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)
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(...)inOnInitialized. Good when the coordination only matters while some part of the UI is alive. This page does exactly that. - Middleware - register an
IBlexMiddlewarethat reacts inOnAction. 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.
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 observedSingle-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.