Blex for Blazor

Core concepts

Stores & state

A store is a partial class that owns a slice of state. The generator turns its fields into reactive properties and gives it a JSON identity.

The [BlexStore] attribute

Mark a partial class with [BlexStore] and the generator makes it derive from BlexStoreBase, emits the reactive members and implements the snapshot methods.

C#
[BlexStore(Name = "counter", Persist = true)]
public partial class CounterStore
{
    // ...
}

[BlexStore] properties

Property Description
Name Display name and slice key. Defaults to the class name. Used in the state tree, DevTools, qualified action names and persistence keys.
Persist When true, the store is rehydrated on startup and saved after every action - provided a persistence provider is registered. See Persistence.

Name is the slice key

Name is what identifies the store in the global state tree, in DevTools, in action labels (counter/Increment) and in persistence keys. Changing it changes the persistence key, which strands previously saved data - bump options.Version and migrate if you rename a persisted store.

Shape requirements

The generator writes a flat, top-level partial declaration, so the class must be one it can extend:

  • declared partial - otherwise BLEX001;
  • a plain class, not a record - BLEX015. Records get value equality and a synthesized clone that fight a mutable reactive container;
  • top-level, non-generic and non-static - BLEX006;
  • with no other base class, since the generated partial adds : Bit.Blex.BlexStoreBase - BLEX016.

Constructors, interfaces, ordinary members and dependency injection all work normally. A store is just a service, so it can take an HttpClient or a repository in its constructor.

A store with dependencies
[BlexStore(Name = "weather")]
public partial class WeatherStore
{
    private readonly HttpClient _http;

    // Constructor injection works exactly as it does for any other scoped service.
    public WeatherStore(HttpClient http) => _http = http;

    [BlexState] private Forecast[]? _forecasts;

    [BlexEffect]
    private async Task OnLoad(CancellationToken ct)
        => Forecasts = await _http.GetFromJsonAsync<Forecast[]>("forecasts.json", ct);
}

[BlexState] fields

Each [BlexState] field becomes a public property whose setter runs through the dispatch pipeline: it compares, assigns, invalidates computed values, notifies subscribers and records the change for time-travel.

C#
[BlexState] private int _count;                       // -> public int Count
[BlexState] private string _name = "";                // -> public string Name
[BlexState] private IReadOnlyList<Todo> _todos = [];   // -> public IReadOnlyList<Todo> Todos
[BlexState] private Filter _filter = Filter.All;       // -> public Filter Filter (enum, stored as a string)

The property name is derived by trimming a leading underscore and capitalizing:

FieldProperty
_countCount
countCount
_isLoggedInIsLoggedIn
Counterror BLEX012 - the field and property would collide

A [BlexState] field must be a writable instance field: static, const and readonly are rejected with BLEX008.

Change detection

Setters compare with EqualityComparer<T>.Default. For value types and records that is structural equality, which is what you want. For a List<T>, an array or any class that does not override equality, it is reference equality - so mutating a collection in place changes nothing as far as Blex is concerned.

Live demo In-place mutation vs. assignment

Both buttons add an item. Only one of them notifies anybody - watch the rendered count and the Inspector's action feed.

Rendered count 1
Actual list length 1
Items alpha

After clicking the red button the two numbers disagree: the list really did grow, but no notification fired, so the UI still shows the stale computed value. Click the blue one and both catch up.

Treat state as immutable

Always assign a new instance: Items = [.. Items, newItem]. Besides making change detection work, immutable slices are what let time-travel, undo/redo and persistence snapshot state safely. For collections that change often, use the entity adapter, which is immutable by construction.

Serialization

Every store gets generated SerializeState() and DeserializeState() methods keyed by the property names. This is the identity used for DevTools, undo/redo and persistence. Values round-trip through System.Text.Json with BlexJson.Options (web defaults, case-insensitive, enums as strings).

Live demo Live snapshot

SerializeState() on the counter store, re-read on every change.

{
  "Count": 0,
  "Step": 1
}

Present-but-null vs. missing

On restore, a property that is present but JSON-null resets the field to default; a missing property leaves the current value untouched. That is what lets you add a new [BlexState] field without invalidating payloads saved by an older build.

BlexStoreBase API

Your store inherits these. The public members are meant for callers; the protected ones are what the generated code calls, and are available if you hand-write a store.

Public

Member Description
string Name The store's slice key.
bool Persist Whether the store opts in to durable persistence.
event Action? StateChanged Raised after state changes so views can re-render. Fires once per action, not once per field.
JsonObject SerializeState() Captures the current state as JSON. Generated.
void DeserializeState(JsonObject) Raw field writes from a snapshot, without notification. Generated. Prefer RestoreState.
void RestoreState(JsonObject) The safe public way to hydrate a store by hand: applies the snapshot, invalidates computed values and raises StateChanged exactly once, without recording an action.
void Batch(string, Action) Groups ad-hoc mutations into one named action - one notification, one history entry, one middleware pass. The equivalent of Pinia's $patch.
void ResetState() Restores the state the store had when it was first registered, recorded as a normal, vetoable ResetState action. No-op if the store was never registered.

Protected

Member Description
bool IsRestoring True while the manager is applying a time-travel or restore snapshot. Use it to skip side-effects that should not run during a rewind.
bool IsObserved Whether anything (middleware, DevTools, subscribers) is watching. Generated wrappers use it to skip argument capture when nobody is listening.
void SetState&lt;T&gt;(ref T, T, string?) The generated property setter. Compares, assigns, invalidates, notifies, and records a standalone Set X action when called outside a dispatch.
void SetEffectState&lt;T&gt;(ref T, T) Assigns a transient effect field (loading/error). Notifies, but never records a time-travel action.
void BeginEffect(ref int) / EndEffect(ref int) Reference-count an effect's in-flight runs so IsLoading stays true until the last one finishes.
void Dispatch(string, Action, BlexActionArg[]?) Runs a mutation as one named, batched, recorded action.
Task DispatchAsync(string, Func&lt;Task&gt;, BlexActionArg[]?) The async variant: state changes render as they happen, but the whole operation records as one action.
void InvalidateComputed() Generated override that marks every memoized computed value stale.
void NotifyRestored() Invalidates computed values and raises StateChanged - for hand-written stores that restore state out of band.

The IBlexStore interface

Everything the manager needs from a store. Implement it directly only if you want a store outside the generator's conventions - you then also give up batching and action recording unless you call the BlexStoreBase helpers yourself.

C#
public interface IBlexStore
{
    string Name { get; }
    bool Persist { get; }
    event Action? StateChanged;
    JsonObject SerializeState();
    void DeserializeState(JsonObject state);
}

Lifetime

AddBitBlexStore<T> registers the store as scoped. In Blazor WebAssembly there is one scope for the whole app, so a store is effectively a singleton. In Blazor Server each circuit gets its own scope, so every user has an independent copy of the state - which is what you want. The same applies to BlexManager, BlexStatePersistor and BlexHistory.

An unhandled error has occurred. Reload 🗙