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.
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.
[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.
private static readonly BlexEntityAdapter<Contact, int> Adapter =
new(c => c.Id, Comparer<Contact>.Create((a, b) => string.CompareOrdinal(a.Name, b.Name)));Every button below is one adapter call inside one action. The list is kept sorted by name automatically.
- Ada Lovelace
- Alan Turing
- Barbara Liskov
- Grace Hopper
BlexEntityState members
| Member | Description |
|---|---|
IReadOnlyList<TKey> Ids |
The ids in display order - insertion order, or the sort comparer's order. |
IReadOnlyDictionary<TKey, TEntity> Entities |
The id-to-entity map. O(1) lookup. |
IEnumerable<TEntity> 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<TEntity, TKey> 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<TEntity>) |
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<TEntity, TEntity>) |
Applies a transform to one entity. No-op if the id is absent. Handles an id change. |
UpdateMany(state, ids, Func<TEntity, TEntity>) |
Applies a transform to each listed id, ignoring missing ones. |
Map(state, Func<TEntity, TEntity>) |
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<TEntity>? SortComparer |
The comparer keeping Ids ordered, or null for insertion order. |
[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.
// 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.
[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.
[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],
};