Blex 0.1.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Blex --version 0.1.0
                    
NuGet\Install-Package Blex -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Blex" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Blex" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Blex" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Blex --version 0.1.0
                    
#r "nuget: Blex, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Blex@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Blex&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Blex&version=0.1.0
                    
Install as a Cake Tool

Blex

Blex

NuGet NuGet NuGet CI License: MIT

Lightweight, source-generator-powered reactive state management for Blazor - with Redux DevTools time-travel built in.

Blex fills a real gap in the Blazor ecosystem. Fluxor is the de-facto Redux library but is widely criticized for boilerplate (separate Action / Reducer / Effect / Feature classes per operation) and for having no first-class DevTools time-travel. Blex keeps the good parts of the Flux model - a single observable state tree, named actions, middleware - while a Roslyn source generator removes the ceremony and a tiny JS bridge wires you straight into the Redux DevTools browser extension.

Why Blex

Fluxor Blex
Define a piece of state Feature + State class one [State] field
Define an action Action class + Reducer method one [Action] method
Action payloads in DevTools/middleware manual automatic (ctx.Args, DevTools payload)
Derived state manual / selectors [Computed] (memoized)
Async side-effects Effect classes [Effect] (auto loading/error)
Effect cancellation / concurrency manual CancellationToken + Latest/Drop/Queue modes
Ad-hoc batched mutations store.Batch(name, ...) (Pinia $patch-style)
Reset to initial state manual store.ResetState()
Middleware yes yes (with veto/filter hooks + payload access)
Granular re-render manual selectors selector Subscribe(...) (+ prev/current, fireImmediately)
Normalized collections manual EntityAdapter / EntityState (+ sorting, UpdateMany, Map)
Persistence 3rd-party [Store(Persist = true)] + debounce + versioning/migrations
Undo / redo BlexHistory (in-app, labeled entries)
Redux DevTools time-travel ✓ built in (+ state/action sanitizers)
Error isolation hook options.OnError
Test helpers Blex.Testing harness (+ WaitForAsync)
Boilerplate high minimal (generated)

Install

dotnet add package Blex          # runtime + source generator
dotnet add package Blex.Blazor   # Blazor integration (BlexProvider, DevTools bridge)
dotnet add package Blex.Testing  # optional, for unit tests

The packages target net8.0, net9.0 and net10.0. The Roslyn generator is packed inside Blex (under analyzers/dotnet/cs), so a package reference is all it takes to light up codegen - there is no Blex.Generators package to install.

The whole store

[Store(Name = "counter")]
public partial class CounterStore
{
    [State] private int _count;
    [State] private int _step = 1;

    [Computed] private int  ComputeDoubleCount() => Count * 2;
    [Computed] private bool ComputeIsEven()      => Count % 2 == 0;

    [Action] private void OnIncrement()       => Count += Step;
    [Action] private void OnSetStep(int step) => Step = step;
    [Action] private void OnReset()           { Count = 0; Step = 1; }
}

The generator emits the reactive Count/Step properties, the memoized DoubleCount/IsEven accessors, the public Increment()/SetStep(int)/Reset() action wrappers, JSON snapshot support and the StoreBase base type.

Conventions

  • State: [State] private T _foo; → public reactive property Foo.
  • Computed: [Computed] on a parameterless ComputeXxx() / GetXxx() method → memoized property Xxx, automatically invalidated whenever state changes.
  • Actions: [Action] on a method named OnXxx → public Xxx(...) wrapper that batches all the mutations inside it into a single, named, time-travel-recorded action. async Task methods are supported (they update the UI as they go but record as one action). Override the name with [Action(Name = "...")]. Action arguments are captured as the action's payload (visible to middleware, subscribers and DevTools).
  • Directly assigning a generated property (e.g. store.Count = 5) is recorded as a Set Count action.
  • Batching from outside: store.Batch("Apply preset", () => { store.Count = 10; store.Step = 5; }) groups ad-hoc mutations into one named action with a single re-render (the $patch/runInAction equivalent).
  • Reset: store.ResetState() returns the store to the state it had when first registered, recorded as a normal, vetoable ResetState action.

Setup

// Program.cs
builder.Services.AddBlex(options =>
{
    options.DevToolsName = "My App";
    options.UseMiddleware(ctx => Console.WriteLine($"[blex] {ctx.QualifiedName}"));
});
builder.Services.AddBlexStore<CounterStore>();
builder.Services.AddBlexStore<TodoStore>();
@* App.razor - wrap your router once *@
<BlexProvider>
    <Router ... />
</BlexProvider>
@* Counter.razor *@
@inherits BlexComponentBase
@inject CounterStore Store

<p>Count: @Store.Count (double: @Store.DoubleCount)</p>
<button @onclick="Store.Increment">+@Store.Step</button>

@code {
    protected override void OnInitialized() => Subscribe(Store);
}

BlexComponentBase.Subscribe(...) re-renders the component whenever a subscribed store changes and unsubscribes automatically on dispose.

Granular subscriptions (selectors)

Subscribe(store) re-renders on any change to that store. For stores with many independent fields, subscribe to a projection instead so unrelated changes don't re-render the component:

protected override void OnInitialized()
    => Subscribe(Store, () => Store.Count); // re-renders only when Count changes

The same primitive is available outside Blazor, with optional previous-value delivery (MobX reaction-style) and fireImmediately:

using var sub = store.Subscribe(() => store.Count, count => Console.WriteLine(count));
using var log = store.Subscribe(() => store.Count,
    (prev, curr) => Console.WriteLine($"{prev} -> {curr}"), fireImmediately: true);

Effects (async with managed loading/error)

[Effect] marks an async method (returning Task/ValueTask) whose loading and error lifecycle is generated for you. The body is still recorded as a single, named, time-travelable action.

[Effect]
private async Task OnLoadUser(int id)
{
    var user = await _api.GetUserAsync(id);
    User = user;
}

The generator emits LoadUser(int) plus reactive LoadUserIsLoading (bool) and LoadUserError (Exception?) properties. The wrapper keeps IsLoading true while any run is in flight (overlapping runs are reference-counted) and captures any thrown exception into Error instead of propagating it.

Cancellation and concurrency

Give the effect a trailing CancellationToken parameter and the generator supplies the token and emits a CancelXxx() method. Concurrency selects how overlapping invocations behave, mirroring the RxJS flattening operators used by NgRx effects:

[Effect(Concurrency = EffectConcurrency.Latest)]   // switchMap: new call cancels the previous
private async Task OnSearch(string query, CancellationToken ct)
{
    Results = await _api.SearchAsync(query, ct);
}
// generated: Task Search(string query)  +  void CancelSearch()
//            bool SearchIsLoading       +  Exception? SearchError
Mode Semantics Typical use
Parallel (default) all runs proceed concurrently independent fetches
Latest new run cancels the previous (switchMap) type-ahead search
Drop ignored while one is running (exhaustMap) double-click-proof submits
Queue runs strictly in arrival order (concatMap) ordered writes

Cancellation through the effect's own token (via CancelXxx() or Latest supersession) is a normal outcome and never populates Error. A foreign OperationCanceledException - an HttpClient timeout, or any cancellation when the effect has no token parameter - is a real failure and is recorded in Error.

Normalized collections (entity adapter)

EntityAdapter<TEntity, TKey> generates CRUD operations over an immutable, id-keyed EntityState<TEntity, TKey> - the same idea as Redux Toolkit's createEntityAdapter.

[Store(Name = "todos")]
public partial class TodoStore
{
    private static readonly EntityAdapter<Todo, int> Adapter = new(t => t.Id);
    [State] private EntityState<Todo, int> _todos = Adapter.GetInitialState();

    [Computed] private int ComputeRemaining() => Todos.All.Count(t => !t.Done);

    [Action] private void OnUpsert(Todo todo) => Todos = Adapter.UpsertOne(Todos, todo);
    [Action] private void OnToggle(int id)    => Todos = Adapter.UpdateOne(Todos, id, t => t with { Done = !t.Done });
    [Action] private void OnRemove(int id)    => Todos = Adapter.RemoveOne(Todos, id);
}

EntityState exposes Ids, Entities, All, Count, Contains(id) and Find(id), and round-trips through JSON for snapshots and persistence. The adapter also offers AddMany, UpsertMany, UpdateMany, Map (transform every entity), RemoveMany, RemoveAll and SetAll, plus an optional sort comparer that keeps Ids ordered after every operation:

private static readonly EntityAdapter<Todo, int> Adapter =
    new(t => t.Id, Comparer<Todo>.Create((a, b) => a.DueDate.CompareTo(b.DueDate)));

Persistence

Mark a store with [Store(Persist = true)] and wire up a storage provider; the store is rehydrated on startup and saved after every action.

[Store(Name = "settings", Persist = true)]
public partial class SettingsStore { [State] private string _theme = "light"; ... }
// Program.cs (Blazor WebAssembly)
builder.Services.AddBlexLocalStoragePersistence();   // or AddBlexSessionStoragePersistence()

<BlexProvider> restores persisted state on init. It also bridges to Blazor's PersistentComponentState automatically (set PersistComponentState="false" to opt out), handing prerendered state to the interactive render to avoid the prerender "double render" flicker. Under Blazor Server prerendering (where JS interop is unavailable), hydration is automatically retried on first render instead of crashing startup. For non-Blazor hosts, implement IBlexStorage and call AddBlexPersistence().

Persistence is production-hardened:

  • Corrupt data never breaks startup - an unreadable payload is reported through OnError, discarded, and removed from storage.
  • Debounce - options.DebounceInterval = TimeSpan.FromMilliseconds(300) coalesces bursts of actions into one write (flushed on dispose, or on demand via persistor.FlushAsync()).
  • Versioning & migrations - bump options.Version when a persisted store's shape changes and supply options.Migrate to upgrade (or discard) old payloads, zustand-persist style:
builder.Services.AddBlexLocalStoragePersistence(options =>
{
    options.Version = 2;
    options.Migrate = (storeName, fromVersion, state) =>
    {
        if (storeName == "settings" && fromVersion < 2)
            state["Theme"] = "system";   // rename/upgrade old values
        return state;                    // return null to discard instead
    };
});
  • Restore write-back - undo/redo and DevTools time-travel write the restored state back to storage, so a reload never resurrects the pre-restore state.
  • Ordered writes - saves are serialized in dispatch order; a stale payload can't overwrite a newer one.

Cross-store coordination

React to one store's actions from elsewhere (e.g. trigger an effect on another store):

manager.SubscribeTo<CounterStore>(ctx => { /* runs after each CounterStore action */ });
manager.SubscribeToAction("Increment", ctx => { ... });
manager.SubscribeAsync(async ctx => await otherStore.Reload());

Middleware: observe and veto

Middleware sees every action after it applies (including its argument payload via ctx.Args), and can veto an action before it runs - also based on the payload:

builder.Services.AddBlex(options =>
{
    options.UseMiddleware(ctx => Console.WriteLine($"{ctx.QualifiedName}({string.Join(", ", ctx.Args)})"));
    options.UseFilter(ctx => !IsReadOnly);   // return false to cancel
    options.OnError = err => _logger.LogWarning(err.Exception, "[blex:{Source}] {Detail}", err.Source, err.Detail);
});

OnError receives every non-fatal failure Blex isolates from the dispatch pipeline (throwing subscribers, middleware, persistence writes, restores) - without it they go to Console.Error.

Undo / redo

BlexHistory provides in-app undo/redo over the whole application state, independent of the DevTools extension:

builder.Services.AddBlexHistory();   // <BlexProvider> starts recording automatically
@inject BlexHistory History
<button @onclick="History.Undo" disabled="@(!History.CanUndo)">Undo @History.NextUndoLabel</button>
<button @onclick="History.Redo" disabled="@(!History.CanRedo)">Redo @History.NextRedoLabel</button>

NextUndoLabel/NextRedoLabel name the action about to be undone/redone (e.g. "Undo counter/Increment"); UndoCount/RedoCount expose stack depths. When persistence is enabled, undo/redo writes the restored state back to storage.

Testing

Blex.Testing provides a zero-setup harness that records dispatched actions:

using var harness = BlexTestHarness.For<CounterStore>();
harness.Store.Increment();
Assert.Equal(new[] { "Increment" }, harness.Log.Names);
Assert.Equal(1, harness.Snapshot()["Count"]!.GetValue<int>());

// Recorded actions include their argument payloads:
harness.Store.Add(5);
Assert.Equal(5, harness.Log.Last!.Args[0].Value);

// Await state conditions instead of sprinkling Task.Delay:
var load = harness.Store.LoadUser(42);
await harness.Store.WaitForAsync(() => !harness.Store.LoadUserIsLoading);

Compile-time diagnostics

The generator validates store shapes and fails fast with precise errors instead of emitting broken code: BLEX001 store not partial · BLEX002/003 underivable action/computed names · BLEX004 computed with parameters · BLEX005 generated-member collisions (including against your own members and StoreBase) · BLEX006 nested/generic stores · BLEX007 non-async effects · BLEX008 static/readonly members · BLEX009 Latest effect without a CancellationToken (warning) · BLEX010 async void actions · BLEX011 discarded action return values (warning) · BLEX012 state field/property name conflicts · BLEX013 by-ref parameters · BLEX014 generic action/effect methods · BLEX015 record stores · BLEX016 conflicting base class.

Time-travel debugging

  1. Install the Redux DevTools browser extension.
  2. Run the app and open DevTools - you'll see an instance named after DevToolsName.
  3. Every action streams in with its argument payload and the resulting state tree.
  4. Use the slider / jump buttons to rewind and replay your application state live.

Under the hood the Blex.Blazor JS bridge talks to window.__REDUX_DEVTOOLS_EXTENSION__, sends each action via send(action, state), and applies JUMP_TO_STATE / JUMP_TO_ACTION / ROLLBACK / RESET / COMMIT messages back onto the stores.

For production, set <BlexProvider EnableDevTools="false"> to disable the connection entirely, or redact sensitive values from the monitor with sanitizers:

builder.Services.AddBlex(options =>
{
    options.RedactDevToolsKeys("token", "password");      // replace matching keys with <redacted>
                                                          // (applies to action payloads too)
    options.DevToolsActionSanitizer = label => label;     // or rewrite action labels
});

Projects

Project Description
src/Blex Core runtime (no JS dependency): StoreBase, attributes, dispatch, middleware, persistence, entity adapter, undo/redo, BlexManager manager.
src/Blex.Generators Roslyn incremental source generator.
src/Blex.Blazor Blazor integration: BlexComponentBase, <BlexProvider>, browser-storage persistence, Redux DevTools bridge.
src/Blex.Testing Test harness and assertions (BlexTestHarness, ActionLog).
src/Demos/Blex.Demo Documentation website: every feature explained with a live, runnable demo.
src/Demos/Blex.Sample Blazor WebAssembly demo (Counter, Todos, Weather).
src/Tests/Blex.Tests xUnit tests for the runtime and generated code.
src/Tests/Blex.Generators.Tests Generator-driver tests: all BLEX diagnostics + emission snapshots.
src/Tests/Blex.Benchmarks BenchmarkDotNet suites (dispatch, fan-out, serialization, entity adapter).

When consumed as a NuGet package, referencing Blex brings the generator automatically (it is packed into analyzers/dotnet/cs). Inside this repo the sample/tests reference the generator project directly as an analyzer.

Build & test

dotnet build src/Blex.slnx
dotnet test src/Blex.slnx
dotnet run --project src/Demos/Blex.Demo     # documentation site
dotnet run --project src/Demos/Blex.Sample   # minimal sample app
dotnet pack src/Blex.slnx -c Release         # nupkg + snupkg into artifacts/packages

Releasing

Package metadata (version, authors, tags, readme, icon, Source Link) is centralised in src/Directory.Build.props. To publish:

  1. Bump <Version> there and move the [Unreleased] section of CHANGELOG.md under the new version heading.
  2. Commit, then tag and push: git tag v0.1.0 && git push origin v0.1.0.
  3. The Release workflow verifies that the tag matches <Version>, builds, tests, packs, and pushes every package plus its symbol package to nuget.org using the NUGET_API_KEY repository secret.

workflow_dispatch runs the same pipeline in dry-run mode and uploads the packages as build artifacts without publishing them.

License

MIT

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (3)

Showing the top 3 NuGet packages that depend on Blex:

Package Downloads
Blex.Blazor

Blazor integration for Blex: the BlexComponentBase base class, <BlexProvider>, browser-storage persistence, and the Redux DevTools time-travel bridge.

Blex.Testing

Testing helpers for Blex: an action-recording harness and assertions for stores and the manager.

Blex.Maui

.NET MAUI integration for Blex: Preferences-backed persistence, automatic startup hydration, and XAML-bindable stores for native (non-Blazor) MAUI apps.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.0 136 8/10/2026
0.2.0 149 7/28/2026
0.1.0 137 7/28/2026