Blex for Blazor

Reacting to state

Entity adapter

Normalized, immutable collections with generated CRUD - the same idea as Redux Toolkit's createEntityAdapter.

Why normalize

Keeping a collection as a flat List<T> means every update is a scan, every lookup by id is a scan, and every mutation risks being done in place - which silently defeats change detection. BlexEntityState<TEntity, TKey> stores an ordered id list plus an id-keyed map, and every operation returns a new instance, so notifications, time-travel and persistence all behave.

The shape
public sealed class BlexEntityState<TEntity, TKey> where TKey : notnull
{
    public IReadOnlyList<TKey> Ids { get; }                       // order
    public IReadOnlyDictionary<TKey, TEntity> Entities { get; }   // lookup
}

Declaring one

The adapter holds the id selector, so it is not serializable - keep it as a static readonly field. The [BlexState] field holds the state itself.

C#
[BlexStore(Name = "contacts")]
public partial class ContactsStore
{
    // Holds the id selector, so it is static and never serialized.
    private static readonly BlexEntityAdapter<Contact, int> Adapter = new(c => c.Id);

    [BlexState] private BlexEntityState<Contact, int> _contacts = Adapter.GetInitialState();

    [BlexAction] private void OnAdd(Contact c)    => Contacts = Adapter.AddOne(Contacts, c);
    [BlexAction] private void OnRemove(int id)    => Contacts = Adapter.RemoveOne(Contacts, id);
}

Sorting

Pass an IComparer<TEntity> and the adapter re-sorts Ids after every operation, so iteration order is always correct without sorting in the view. Without one, insertion order is preserved.

C#
private static readonly BlexEntityAdapter<Contact, int> Adapter =
    new(c => c.Id, Comparer<Contact>.Create((a, b) => string.CompareOrdinal(a.Name, b.Name)));
Live demo Normalized contacts

Every button below is one adapter call inside one action. The list is kept sorted by name automatically.

Filter:
Count 4
Starred 2
Ids (ordered) [1, 3, 4, 2]
  • Ada Lovelace Research · id 1
  • Alan Turing Research · id 3
  • Barbara Liskov Languages · id 4
  • Grace Hopper Compilers · id 2

BlexEntityState members

Member Description
IReadOnlyList&lt;TKey&gt; Ids The ids in display order - insertion order, or the sort comparer's order.
IReadOnlyDictionary&lt;TKey, TEntity&gt; Entities The id-to-entity map. O(1) lookup.
IEnumerable&lt;TEntity&gt; All The entities in id order. [JsonIgnore].
int Count Number of stored entities. [JsonIgnore].
bool Contains(TKey) Whether an entity with that id exists.
TEntity? Find(TKey) The entity for an id, or default.
TEntity this[TKey] The entity with that id, throwing KeyNotFoundException when there is none - for the lookups where absence is a bug, not a case.
bool TryGet(TKey, out TEntity) Looks an entity up and reports whether it was there. Use it over Find for struct entities, where default is a legitimate value - and to avoid hashing the key twice with Contains + Find.
static BlexEntityState&lt;TEntity, TKey&gt; Empty A shared, read-only empty state.

BlexEntityAdapter operations

Every operation takes the current state and returns the next one. Assign the result back to the [BlexState] property - that assignment is what makes the change visible.

Member Description
GetInitialState() The empty state for this entity type.
GetInitialState(IEnumerable&lt;TEntity&gt;) An initial state seeded with the given entities.
AddOne(state, entity) Appends an entity. No-op if the id already exists.
AddMany(state, entities) Appends many, skipping ids that already exist.
UpsertOne(state, entity) Adds, or replaces the entity with that id.
UpsertMany(state, entities) Adds or replaces many.
UpdateOne(state, id, Func&lt;TEntity, TEntity&gt;) Applies a transform to one entity. No-op if the id is absent. Handles an id change.
UpdateMany(state, ids, Func&lt;TEntity, TEntity&gt;) Applies a transform to each listed id, ignoring missing ones.
Map(state, Func&lt;TEntity, TEntity&gt;) Applies a transform to every entity.
SetOne(state, entity) Replaces the entity with that id. No-op if the id is absent - unlike UpsertOne, it never inserts.
SetMany(state, entities) Replaces each entity whose id is already present, ignoring the rest.
RemoveOne(state, id) Removes one entity. No-op if absent.
RemoveMany(state, ids) Removes the listed entities.
RemoveAll(state) Removes everything.
SetAll(state, entities) Replaces the whole collection.
SelectId(entity) The id, as this adapter derives it. Use it instead of repeating the key expression at each call site, where a second copy can silently diverge.
IComparer&lt;TEntity&gt;? SortComparer The comparer keeping Ids ordered, or null for insertion order.
Typical actions
[BlexAction] private void OnUpsert(Todo todo) => Todos = Adapter.UpsertOne(Todos, todo);
[BlexAction] private void OnToggle(int id)    => Todos = Adapter.UpdateOne(Todos, id, t => t with { Done = !t.Done });
[BlexAction] private void OnRemove(int id)    => Todos = Adapter.RemoveOne(Todos, id);
[BlexAction] private void OnLoaded(Todo[] t)  => Todos = Adapter.SetAll(Todos, t);

Operations are no-ops when they cannot apply

AddOne ignores an entity whose id already exists, UpdateOne and RemoveOne ignore a missing id, and UpdateMany skips ids that are not present. Each of those returns the same state instance, so no notification fires and no action is recorded for a change that did not happen.

Set vs Upsert

UpsertOne inserts an unknown id; SetOne refuses to. That is the difference between "save this, wherever it came from" and "apply this update to a row I already have". Reach for SetOne/SetMany when a late response must not resurrect a record that was deleted while it was in flight - a stale PUT reply arriving after the user removed the row leaves the list alone instead of putting the row back.

A late response must not resurrect a deleted row
// The row may have been deleted while the save was in flight. UpsertOne would put it back.
[BlexEffect]
private async Task OnSave(Todo todo)
{
    var saved = await _api.PutAsync(todo);
    Todos = Adapter.SetOne(Todos, saved);      // applies only if the id is still there
}

// Same rule for a batch: ids the user removed in the meantime are ignored.
[BlexAction] private void OnPatched(Todo[] changed) => Todos = Adapter.SetMany(Todos, changed);

Writing the same value twice costs nothing

Every write operation compares before it commits. Re-applying a value that is already there - SetAll with an unchanged server response, UpsertMany where only one of fifty rows actually moved - returns the same state instance, so nothing notifies, nothing persists, and no undo step appears for a mutation that did not happen. Polling a list endpoint on a timer is therefore free while the data is unchanged.

Idempotent reseeding
[BlexEffect(Mode = BlexEffectConcurrency.Drop)]
private async Task OnPoll()
{
    var fresh = await _api.GetTodosAsync();

    // Identical payload -> same instance back -> no notification, no persist, no undo step.
    Todos = Adapter.SetAll(Todos, fresh);
}

Changing an entity's id

UpdateOne handles an update function that changes the id: the entity moves to the new key and keeps its position in the order. If the new id already belongs to another entity, that one is overwritten and the old slot is dropped, so an id never appears twice in the ordered list.

Serialization

BlexEntityState round-trips through JSON as { "ids": [...], "entities": { ... } }, so a normalized collection persists and time-travels like any other state. Count and All are marked [JsonIgnore] - they are derived from Ids.

TKey must survive being a JSON dictionary key

Entities serializes as a JSON object, whose keys are strings. int, string and Guid all work. A composite or custom key type needs a converter that round-trips it as a dictionary key.

Deriving views

Pair the adapter with [BlexComputed] to project filtered or aggregated views. They are memoized, so a filtered list is rebuilt once per change rather than once per render.

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

[BlexComputed]
private IReadOnlyList<Todo> ComputeVisible() => Filter switch
{
    Filter.Active => [.. Todos.All.Where(t => !t.Done)],
    Filter.Done   => [.. Todos.All.Where(t => t.Done)],
    _             => [.. Todos.All],
};
An unhandled error has occurred. Reload 🗙