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.
@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.
protected override void OnInitialized() => Subscribe(Counter, Todos, Settings);BlexComponentBase
| Member | Description |
|---|---|
void Subscribe(params ReadOnlySpan<IBlexStore>) |
Re-render on any change to the given stores. Duplicate subscriptions are ignored. |
void Subscribe<T>(IBlexStore, Func<T>, IEqualityComparer<T>?) |
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.
// Re-renders only when Count changes; a change to Step is ignored.
protected override void OnInitialized()
=> Subscribe(Counter, () => Counter.Count);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.
Subscribe(Store, () => Store.Count)
Renders only when Count changes.
Outside components
The same primitive is a plain extension method on IBlexStore, usable anywhere. It
returns a token - dispose it to stop observing.
using var sub = store.Subscribe(
() => store.Count,
count => Console.WriteLine($"count is now {count}"));
// ... later
sub.Dispose(); // stop observingBlexStoreSubscriptionExtensions
| Member | Description |
|---|---|
IDisposable Subscribe<T>(this IBlexStore, Func<T> selector, Action<T> onChanged, IEqualityComparer<T>? comparer = null, bool fireImmediately = false) |
Invoke onChanged with the new value whenever the projection changes. |
IDisposable Subscribe<T>(this IBlexStore, Func<T> selector, Action<T, T> onChanged, IEqualityComparer<T>? 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.
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.
// 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<T>(comparer?) |
Element-by-element, in order. The default choice for a projected list, filter or LINQ query. |
BlexComparers.Set<T>(comparer?) |
Same elements, order and repetition ignored - for a selection whose order is incidental. |
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.
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.
@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.
@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.
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.