Blex for Blazor

Reacting to state

Components & selectors

Re-render on the state you actually use - from a whole store down to a single projected value.

BlexComponentBase

Inherit from BlexComponentBase and call Subscribe(...) in OnInitialized. The component re-renders whenever a subscribed store changes and detaches every handler on dispose - there is no StateHasChanged to call and nothing to clean up.

Razor
@inherits BlexComponentBase
@inject CounterStore Counter

<p>Count: @Counter.Count</p>
<button @onclick="Counter.Increment">+1</button>

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

You can subscribe to several stores at once; the call is idempotent per store.

C#
protected override void OnInitialized() => Subscribe(Counter, Todos, Settings);

BlexComponentBase

Member Description
void Subscribe(params ReadOnlySpan&lt;IBlexStore&gt;) Re-render on any change to the given stores. Duplicate subscriptions are ignored.
void Subscribe&lt;T&gt;(IBlexStore, Func&lt;T&gt;, IEqualityComparer&lt;T&gt;?) Re-render only when the projected value changes.
void OwnsSubscription(IDisposable) Dispose the given token when the component is disposed.
void Dispose(bool disposing) protected virtual hook for your own cleanup. Store subscriptions are already detached when it runs - override this instead of implementing IDisposable yourself.

Selector subscriptions

Subscribe(store) fires on any change to that store. On a store with many independent fields - or one big collection - that re-renders components that do not care. Subscribe to a projection instead, and the component only wakes up when the projected value actually changes.

C#
// Re-renders only when Count changes; a change to Step is ignored.
protected override void OnInitialized()
    => Subscribe(Counter, () => Counter.Count);
Live demo Whole store vs. selector

Two identical panels, subscribed differently. Change only the Step field and watch which one re-renders.

Subscribe(Store)

Renders on any change to the store.

Count (displayed) 0
Renders 0

Subscribe(Store, () => Store.Count)

Renders only when Count changes.

Count (displayed) 0
Renders 0
Count 0
Step 1

Outside components

The same primitive is a plain extension method on IBlexStore, usable anywhere. It returns a token - dispose it to stop observing.

C#
using var sub = store.Subscribe(
    () => store.Count,
    count => Console.WriteLine($"count is now {count}"));

// ... later
sub.Dispose();   // stop observing

BlexStoreSubscriptionExtensions

Member Description
IDisposable Subscribe&lt;T&gt;(this IBlexStore, Func&lt;T&gt; selector, Action&lt;T&gt; onChanged, IEqualityComparer&lt;T&gt;? comparer = null, bool fireImmediately = false) Invoke onChanged with the new value whenever the projection changes.
IDisposable Subscribe&lt;T&gt;(this IBlexStore, Func&lt;T&gt; selector, Action&lt;T, T&gt; onChanged, IEqualityComparer&lt;T&gt;? comparer = null, bool fireImmediately = false) The same, but the callback receives (previous, current).

Previous and current

The two-argument overload delivers (previous, current), which is what you want for transition logic - analytics, animations, "changed from X to Y" logging. This is MobX's reaction shape.

C#
var sub = store.Subscribe(
    () => store.Status,
    (previous, current) => _analytics.Track("status-changed", previous, current),
    fireImmediately: true);

Custom equality

Both overloads take an optional IEqualityComparer<T>. Supply one when the projection returns something whose default equality is not what you mean - a collection, for instance, where you want to compare contents rather than references.

This is the case that quietly defeats selectors. A projection like () => Contacts.All.Where(c => c.Starred) builds a new object on every evaluation, so the default reference comparison always reports a change and the subscription fires on every action - precisely what selecting a projection was meant to avoid. BlexComparers supplies the two comparers that fix it, the same job zustand's shallow does.

C#
// Fires on every action: Where(...) is a new object each time.
store.Subscribe(() => store.Contacts.All.Where(c => c.Starred), Refresh);

// Fires only when the starred contacts actually differ.
store.Subscribe(
    () => store.Contacts.All.Where(c => c.Starred),
    Refresh,
    BlexComparers.Sequence<Contact>());

// Order is incidental for a selection of ids -- compare as a set.
store.Subscribe(
    () => store.SelectedIds,
    Refresh,
    BlexComparers.Set<int>());

// Components take the same argument:
Subscribe(Store, () => Store.Visible, BlexComparers.Sequence<Contact>());

BlexComparers

Member Description
BlexComparers.Sequence&lt;T&gt;(comparer?) Element-by-element, in order. The default choice for a projected list, filter or LINQ query.
BlexComparers.Set&lt;T&gt;(comparer?) Same elements, order and repetition ignored - for a selection whose order is incidental.
Live demo Same projection, two comparers

Both watch the starred contacts. Star and unstar to change the projection; add or remove an unstarred contact to leave it alone but still run it.

Starred now 2
Default equality: fired 0
Sequence comparer: fired 0

Adding an unstarred contact re-runs both projections and produces an identical sequence. Only the left-hand counter moves - and it is the one that would have re-rendered a component for nothing.

fireImmediately

Pass fireImmediately: true to invoke the callback once at subscription time with the current value, so an observer does not have to seed itself. With the two-argument overload, previous and current are both the value at subscription. If that first call throws, the subscription is detached before the exception surfaces - the caller never received a token, so a handler left attached would be unstoppable.

Across stores

BlexManager carries the same pair of overloads for a projection that spans stores. Without it, a value derived from two stores has to be recomputed on every action of every store - the cross-store equivalent of re-rendering the whole app on any change.

One callback, two stores
@inject BlexManager Manager
@inject CartStore Cart
@inject ProfileStore Profile

protected override void OnInitialized()
    => OwnsSubscription(Manager.Subscribe(
           () => Cart.Total > Profile.CreditLimit,     // spans two stores
           overLimit => _banner = overLimit ? "Over your limit" : null));

// The (previous, current) overload and a custom comparer work the same as on a store:
Manager.Subscribe(
    () => Cart.Total,
    (previous, current) => _analytics.CartValueChanged(previous, current));

A manager selector runs after every action

The store-level overload only runs when that store changes; the manager-level one runs after every action in the application. Keep the projection cheap - read already-memoized [BlexComputed] properties rather than recomputing over collections inside it.

Owning other subscriptions

OwnsSubscription(IDisposable) hands any token to the component so it is disposed along with it - a manager subscription, a selector token you made yourself, anything.

Razor
@code {
    [Inject] private BlexManager Manager { get; set; } = default!;

    protected override void OnInitialized()
    {
        Subscribe(Store);
        OwnsSubscription(Manager.SubscribeTo<OrdersStore>(ctx => Refresh()));
    }
}

The raw event

Under everything is IBlexStore.StateChanged, a plain Action raised once per action after the state has settled. Use it directly when you want no machinery at all - just remember to unsubscribe.

C#
void OnChanged() => Console.WriteLine("something changed");

store.StateChanged += OnChanged;
// ...
store.StateChanged -= OnChanged;

Rendering is already batched

An action that changes five fields raises StateChanged once, not five times, so you do not need selectors to avoid render storms within an action. Reach for them when a component should ignore whole categories of change on a shared store.

Selectors compare, they do not deep-compare

A selector returning Store.Items (a list) fires whenever the reference changes - which, if you follow the immutable-assignment rule, is every time the collection is replaced, even when the contents are equal. Project to something small and comparable (Items.Count, an id, a formatted string), or pass BlexComparers.Sequence<T>().

Manager selectors also see restores

Undo/redo, a DevTools time-travel jump and an incoming cross-tab change all move state without dispatching an action. A manager-level projection is re-evaluated after those too, so it never keeps reporting a value the application no longer holds.

An unhandled error has occurred. Reload ๐Ÿ—™