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.
[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- otherwiseBLEX001; - 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.
[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.
[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:
| Field | Property |
|---|---|
_count | Count |
count | Count |
_isLoggedIn | IsLoggedIn |
Count | error 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.
Both buttons add an item. Only one of them notifies anybody - watch the rendered count and the Inspector's action feed.
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).
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<T>(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<T>(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<Task>, 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.
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.