Blex for Blazor

State lifecycle

Undo & redo

In-app history over the entire application state, independent of the browser extension.

Setup

Register it once. <BlexProvider> calls Start() for you - deliberately after rehydration, so the baseline that undo ultimately returns to is the state the user actually loaded, not the pre-hydration defaults.

C#
Program.cs
builder.Services.AddBitBlexHistory();              // default: 100 entries
builder.Services.AddBitBlexHistory(maxEntries: 50);
Razor
@inject BlexHistory History

<button @onclick="History.Undo" disabled="@(!History.CanUndo)">Undo</button>
<button @onclick="History.Redo" disabled="@(!History.CanRedo)">Redo</button>
Live demo Undo across every store at once

History snapshots the whole state tree, so a single Undo steps back through whichever store changed last - counter or contacts.

Undo depth 0
Redo depth 0
Max entries 50

counter Count = 0

contacts 4 contacts

Interleave the two: increment, add a contact, increment again - then undo three times and watch each store roll back in turn.

Jumping several steps at once

Undo() and Redo() are the one-step cases of Jump(n): negative walks back, positive walks forward. The intermediate states are never applied - the stacks are walked first and only the destination is restored, so a ten-step undo costs one render, one storage write and one Changed raise instead of ten of each.

UndoLabels and RedoLabels name the steps in navigation order, which is what a history panel renders; JumpToPast / JumpToFuture address them the way redux-undo does.

C#
History.Jump(-3);          // undo three steps, applying only the destination
History.Jump(2);           // redo two
History.JumpToPast(0);     // the oldest state still retained
History.JumpToFuture(0);   // the same state Redo() would reach

// Labels in navigation order, for a history panel:
foreach (var label in History.UndoLabels)   // "counter/Increment", "contacts/Add", ...
    Console.WriteLine(label);
Live demo A history panel

Pick any point on either stack and land on it directly. Make a few changes below first, then step back through them.

Undo stack 0

Nothing to undo yet.

Redo stack 0

Nothing to redo yet.

counter/Count 0
contacts 4

Grouping keystrokes into one step

A filter removes an action from the stack entirely. Grouping keeps it but folds it into the step already open, so a burst of related actions - every keystroke in one field, every frame of one drag - costs a single undo. Return the same non-null key for actions that belong together, and null for anything that deserves its own step.

Registration
// Every keystroke in one field is one undo step; a different field starts a new one.
builder.Services.AddBitBlexHistory(
    groupBy: ctx => ctx.ActionName.StartsWith("Set ") ? ctx.QualifiedName : null);

// Group a whole drag by the store that is churning, regardless of action name:
builder.Services.AddBitBlexHistory(
    groupBy: ctx => ctx.Store.Name == "canvas" ? "drag" : null);

Only consecutive actions merge: a differently-keyed action, an ungrouped one, a Checkpoint, an undo or a redo all close the open group, so the next action starts a fresh step. That is what stops a step the user has already stepped away from from quietly absorbing new work.

What the user experiences
// groupBy: ctx => ctx.ActionName.StartsWith("Set ") ? ctx.QualifiedName : null

store.Title = "h";     // opens a step
store.Title = "he";    // merges
store.Title = "hel";   // merges
store.Publish();       // its own step (and closes the group)
store.Title = "hello"; // opens a NEW step

// Three steps, not five:
History.Undo();        // -> "hel", still published
History.Undo();        // -> "hel", unpublished
History.Undo();        // -> the title before the first keystroke

Filter, group or checkpoint?

Filter when the action should never be a destination (telemetry, cursor position). Group when a burst of actions is one edit to the user. Checkpoint when only your component knows where the boundary is - a field losing focus, a wizard step completing. They compose: group the keystrokes, checkpoint on blur.

API

BlexHistory

Member Description
void Start() Captures the baseline and begins recording. Idempotent. Called by <BlexProvider> after rehydration.
void Undo() Restores the previous state. No-op when CanUndo is false. Shorthand for Jump(-1).
void Redo() Re-applies the most recently undone state. No-op when CanRedo is false. Shorthand for Jump(1).
void Jump(int steps) Moves several steps in one restore - negative undoes, positive redoes. Clamped to what the stacks hold; intermediate states are never applied.
void JumpToPast(int index) Jumps to an undo-stack entry, 0 being the oldest retained state.
void JumpToFuture(int index) Jumps to a redo-stack entry, 0 being the state a single Redo() would reach.
IReadOnlyList&lt;string?&gt; UndoLabels Labels of the available undo steps, nearest first - what a history panel renders.
IReadOnlyList&lt;string?&gt; RedoLabels Labels of the available redo steps, nearest first. Item i is JumpToFuture(i).
void Checkpoint(string? label = null) Commits the current state as an undo step, labelled label (default "Checkpoint"). Pairs with a filter: the filter keeps noise out, this marks the boundaries. A checkpoint on unchanged state adds nothing.
void Clear() Drops both stacks and takes the current state as the new baseline.
bool CanUndo / CanRedo Whether there is anything to step to. Bind to a button's disabled.
int UndoCount / RedoCount Stack depths.
string? NextUndoLabel Qualified name of the action Undo() would revert, or null.
string? NextRedoLabel Qualified name of the action Redo() would re-apply, or null.
int MaxEntries Retention cap, set at construction. Default 100.
event Action? Changed Raised whenever availability changes - bind button state to it.
void Dispose() Detaches from the manager and stops recording.

How it works

  1. Start() captures the current global state as the baseline and subscribes to the manager.
  2. After each action, the previous snapshot is pushed onto the undo stack and the new one becomes the present. The redo stack is cleared, because you have branched away from it.
  3. Undo() pushes the present onto the redo stack, pops the undo stack and restores that snapshot through RestoreGlobalState.
  4. Restoring does not record an action, so stepping backwards never pollutes the history you are stepping through.

Snapshots are whole state trees. MaxEntries caps how many are retained - older ones are discarded from the bottom of the stack. The default is 100.

Labels

Each entry remembers the qualified name of the action that produced it, so a button can say what it is about to undo rather than just "Undo".

Razor
<button @onclick="History.Undo" disabled="@(!History.CanUndo)">
    Undo @History.NextUndoLabel      @* e.g. "Undo counter/Increment" *@
</button>

External restores are absorbed

If something else changes the state without an action - a DevTools time-travel jump, or a manual RestoreGlobalState - history refreshes its present snapshot so the next undo diffs from what the user can actually see, rather than from a stale pre-jump state.

Undo/redo and persistence cooperate

A restore raises StateRestored, and the persistor writes every persisted slice back in response. So undoing and then reloading the page keeps the undone state - the reload cannot resurrect what you just stepped away from.

Choosing what earns an undo step

By default every action becomes its own step. That is rarely what a user means by "undo" once a text box is involved: typing "hello" into a @bind-ed property records five actions, so undo walks back one letter at a time. Pass a filter - a predicate over the action context - to decide which actions get their own step. It is the same idea as redux-undo's filter.

Registration
// Direct property assignments record as "Set X" -- typically one per keystroke.
// Skip them so undo steps between deliberate actions instead.
builder.Services.AddBitBlexHistory(
    maxEntries: 100,
    filter: ctx => !ctx.ActionName.StartsWith("Set "));

// Or exempt a whole store whose churn is noise (telemetry, live cursor position):
builder.Services.AddBitBlexHistory(filter: ctx => ctx.Store.Name != "presence");

A filtered action still applies, and it still moves the present forward. It simply does not become a separate step, so the next undo reverts it together with the recorded action before it. That is what keeps undo landing on states the user recognizes: never on a half-typed word, and never on a state they never saw.

What the user experiences
// filter: ctx => ctx.ActionName != "Add"

counter.Increment();   // recorded -> 1
counter.Add(10);       // filtered -> 11   (applies, but no new step)
counter.Increment();   // recorded -> 12

History.Undo();        // -> 11, not 1: the step holds the post-filter state

The filter fails open

If the predicate throws, the action is recorded anyway and the exception is reported through OnError with source "history". Keeping an extra undo step is a smaller failure than silently losing one the user is about to reach for.

Marking your own boundaries

A filter alone leaves the filtered work with no boundary of its own, so the next recorded action swallows it: undo would discard the whole typed edit and the button press after it. Checkpoint(label) commits the current state as an undo step on demand - call it where the user would expect a boundary: a pause in typing, a field losing focus, a wizard step completing.

Debounced boundaries
// Registration: keystroke-level writes never get their own step...
builder.Services.AddBitBlexHistory(filter: ctx => !ctx.ActionName.StartsWith("Set "));

// ...so the component marks the boundaries instead.
@inject BlexHistory History

private void OnBlur() => History.Checkpoint("Edit title");

// Or on a pause in typing, via a debounced effect:
[BlexEffect(DebounceMilliseconds = 500)]
private async Task OnSettled()
{
    await Task.CompletedTask;
    _history.Checkpoint("Edit");
}

Consecutive checkpoints with nothing in between collapse into one, so a "user stopped typing" timer can call it freely without growing the stack while the state sits still.

Scoping history to one store

BlexHistory is deliberately global: it snapshots the whole tree, which is what makes cross-store undo coherent. For per-store history, keep your own stack of that store's snapshots and restore them with RestoreState, which applies a snapshot without recording an action.

C#
private readonly Stack<JsonObject> _undo = new();

void Checkpoint() => _undo.Push(store.SerializeState());

void UndoOne()
{
    if (_undo.TryPop(out var snapshot))
        store.RestoreState(snapshot);   // applies it without recording an action
}

Snapshots cost memory

Every entry is a full serialized copy of every registered store. With large collections in state, lower maxEntries - or scope history to the store that needs it.

An unhandled error has occurred. Reload 🗙