Blex for Blazor

State lifecycle

Persistence

Opt a store in, register a storage provider, and its state survives a reload - with debouncing, versioning and migrations included.

Opting in

Set Persist = true on the store and register a provider. The store is rehydrated during startup and written back after every action that touches it.

C#
[BlexStore(Name = "settings", Persist = true)]
public partial class SettingsStore
{
    [BlexState] private string _accent = "violet";
    [BlexState] private Density _density = Density.Comfortable;
}
C#
Program.cs
// Blazor WebAssembly / Blazor Server
builder.Services.AddBitBlexLocalStoragePersistence();

// ... or with options
builder.Services.AddBitBlexLocalStoragePersistence(options =>
{
    options.KeyPrefix = "myapp:";
    options.DebounceInterval = TimeSpan.FromMilliseconds(300);
    options.DebounceMaxDelay = TimeSpan.FromSeconds(2);
    options.Version = 1;
});

Providers

Call Description
AddBitBlexLocalStoragePersistence(…) Blazor: window.localStorage. Survives browser restarts.
AddBitBlexSessionStoragePersistence(…) Blazor: window.sessionStorage. Cleared when the tab closes.
AddBitBlexPersistence(…) Core: wires the persistor to whatever IBlexStorage you have registered.
Live demo This page's settings are persisted

Change something, then reload the browser. The values come back - and so does the raw payload below, read straight out of localStorage.

Accent:
Density:
Summary (computed) violet / Comfortable / numbered
Storage key blex-docs:settings

This site runs with SyncAcrossTabs on. Open it in a second tab, change the theme there, and watch this one follow without a reload.

Note the envelope

Because this site sets options.Version = 1, the payload is wrapped as { "__blexVersion": 1, "state": { … } }. With Version left at 0 the state is stored bare, with no envelope and no version checking.

Options

BlexPersistenceOptions

Member Description
string KeyPrefix Prepended to every store's key. Default "blex:", giving blex:settings.
TimeSpan? DebounceInterval Coalesce a burst of actions into one write after this quiet period. Default null - write after every action.
TimeSpan? DebounceMaxDelay Upper bound on how long a debounced write may be postponed under a steady stream of actions. Only meaningful with DebounceInterval.
int Version Schema version. Above zero, payloads are wrapped in a version envelope and checked on load. Default 0 - no envelope, no checking.
Func<string, int, JsonObject, JsonObject?>? Migrate Called when a stored version differs from Version. Receives the store name, the stored version and the state; return the migrated state, or null to discard.
Func<string, JsonObject, JsonObject?>? Partialize Projects each store's snapshot before it is written. Receives the store name and its state; return what to persist, or null to skip that store's write and leave storage as-is.
Action<string, JsonObject?>? OnRehydrated Called once per persistent store as its stored slice is applied. Receives the store name and the applied state, or null when nothing was stored.
bool SkipHydration Start saving without restoring. Call RehydrateAsync() to apply stored state later. Default false.
bool SyncAcrossTabs Apply state written by another tab as soon as it lands, instead of only at the next startup. Requires a storage implementing IBlexExternalStorageChange (the browser localStorage provider does). Default false.
Action<string, JsonObject>? OnExternalChange Called after another tab's state has been applied, with the store name and the applied slice.
Func<string, JsonObject, JsonObject, JsonObject?>? Merge Combines a stored payload with the store's current state before it is applied. Receives the store name, the stored slice and a fresh snapshot of the live store; return what to apply, or null to discard. Runs for cross-tab changes too.

Debouncing

Without a debounce, every action writes. For a store that changes on every keystroke or drag that is a lot of serialization and a lot of storage churn. DebounceInterval coalesces a burst into one write once things go quiet.

C#
builder.Services.AddBitBlexLocalStoragePersistence(options =>
{
    // One write per quiet 300 ms...
    options.DebounceInterval = TimeSpan.FromMilliseconds(300);
    // ...but never postpone a pending change by more than 2 s.
    options.DebounceMaxDelay = TimeSpan.FromSeconds(2);
});

Debouncing is trailing-edge, so a continuous stream of actions would postpone the write forever. DebounceMaxDelay caps that: once the oldest pending change reaches that age, a save is forced. Pending writes are also flushed on dispose, and on demand via FlushAsync().

Versioning and migrations

When a persisted store's shape changes, bump Version. On load, a payload whose stored version differs is handed to Migrate; return the upgraded state, or null to discard it. Without a migration hook, a mismatched payload is simply dropped.

C#
builder.Services.AddBitBlexLocalStoragePersistence(options =>
{
    options.Version = 2;
    options.Migrate = (storeName, fromVersion, state) =>
    {
        if (storeName != "settings")
            return state;

        if (fromVersion < 2)
        {
            // v1 stored a bool; v2 stores an enum.
            var compact = state["Compact"]?.GetValue<bool>() ?? false;
            state["Density"] = compact ? "Compact" : "Comfortable";
            state.Remove("Compact");
        }

        return state;      // or null to discard the payload entirely
    };
});

fromVersion 0 means "pre-envelope"

A payload written before you enabled versioning has no envelope, so it arrives as version 0. Handle that case explicitly - usually by accepting it as-is or discarding it, depending on whether the shape actually changed.

Persisting only part of a store

Partialize receives each store's snapshot on its way to storage and returns what should actually be written - zustand's option of the same name. Use it to keep transient state out of storage: a search box's text, an "is the drawer open" flag, a cached response that must be re-fetched rather than restored. Returning null skips that store's write entirely and leaves whatever is already stored untouched.

Drop transient fields
builder.Services.AddBitBlexLocalStoragePersistence(options =>
{
    options.Partialize = (storeName, state) =>
    {
        if (storeName == "search")
        {
            // The query text and the fetched results are per-visit; the saved filters are not.
            state.Remove("Query");
            state.Remove("Results");
        }

        // Never write a half-finished upload back to storage.
        if (storeName == "uploads" && state["Status"]?.GetValue<string>() == "InFlight")
            return null;

        return state;
    };
});

Omitted keys come back as their declared defaults

Rehydration applies only the keys present in the payload, so a field you left out is not wiped - it simply keeps the value the store's field initializer gives it. That is what makes Partialize safe to widen or narrow between releases.

Reacting to rehydration

OnRehydrated fires once per persistent store as StartAsync applies it, with the slice that was applied - or null when nothing was stored, which is how you tell a returning user from a first visit. It is the natural place to kick off a refresh, mark a splash screen done, or record the hydration in telemetry. A callback that throws is reported through OnError and never derails startup.

First visit vs returning
options.OnRehydrated = (storeName, state) =>
{
    if (storeName != "settings")
        return;

    if (state is null)
        _telemetry.FirstVisit();          // nothing was stored
    else
        _telemetry.Restored(state.Count); // came back with saved settings
};

Deferring hydration

Sometimes state must not be restored at startup: a per-user payload that only makes sense once sign-in resolves, or a draft the user should be asked about before it reappears. Set SkipHydration and call RehydrateAsync() when the moment arrives. Saving is live from the start either way, so nothing changed in the meantime is lost.

Hydrate after sign-in
builder.Services.AddBitBlexLocalStoragePersistence(options => options.SkipHydration = true);

// ...later, once you know who the user is:
@inject BlexStatePersistor Persistor

private async Task OnSignedIn()
{
    await Persistor.RehydrateAsync();
}

Two tabs, one state

By default, two open tabs of the same app quietly fight. Each saves its own state after every action, so whichever tab acted last wins and the other's changes disappear on its next reload. Set SyncAcrossTabs and a change made in one tab is re-read and applied in the others as soon as it lands, so they converge instead.

Opting in
builder.Services.AddBitBlexLocalStoragePersistence(options =>
{
    options.SyncAcrossTabs = true;

    options.OnExternalChange = (storeName, state) =>
        _toasts.Show($"{storeName} was updated in another tab");
});

Incoming state is applied as a restore, not an action: it raises the usual change notification so the UI updates, but records no time-travel entry and is not written straight back - echoing it would bounce the same value between tabs forever. Migrate runs on it exactly as at startup, so a tab on an older build still reads what a newer one wrote. OnExternalChange tells you it happened.

localStorage only, and last writer still wins

sessionStorage is per-tab, so there is no other instance to hear from and the option is inert there. Synchronization does not merge concurrent edits: two tabs editing the same store still resolve to whoever wrote last. What it removes is the silence - both tabs end up showing the same state rather than diverging until the next reload.

Knowing when the real state has landed

Hydration is not instantaneous: browser storage sits behind JS interop, a Blazor Server prerender cannot reach it at all, and SkipHydration defers it on purpose. Until it finishes, a component bound to a persisted store is rendering declared defaults, not the user's data - which is how a full cart briefly renders as "0 items". HasHydrated is the flag to branch on, Hydrated the event to re-render from, and WhenHydratedAsync() the awaitable form.

Razor
@inject BlexStatePersistor Persistor
@inject CartStore Cart

@if (!Persistor.HasHydrated)
{
    <CartSkeleton />        @* not "0 items" -- we do not know yet *@
}
else
{
    <CartSummary Total="Cart.Total" />
}

@code {
    protected override void OnInitialized() => Persistor.Hydrated += Rerender;

    private void Rerender() => _ = InvokeAsync(StateHasChanged);

    protected override void Dispose(bool disposing) => Persistor.Hydrated -= Rerender;
}
Live demo This page's own hydration state

Already true by the time you can read this - <BlexProvider> awaits StartAsync before the first interactive render. The interesting case is SkipHydration, or a slow storage provider.

HasHydrated False
settings/Accent violet

Deciding how stored state meets live state

The default is already a shallow merge: the generated deserializer only writes the properties the payload actually contains, so a key it omits keeps whatever the store currently holds. Merge is for when that is not enough - a nested object that must be combined key by key rather than replaced wholesale, or a value the live store must win on because it was assigned before hydration finished.

Keeping a live value
builder.Services.AddBitBlexLocalStoragePersistence(options =>
{
    options.Merge = (storeName, persisted, current) =>
    {
        // A connection id assigned during startup must survive rehydration.
        if (storeName == "session")
            persisted["ConnectionId"] = current["ConnectionId"]?.DeepClone();

        return persisted;   // or null to discard the payload entirely
    };
});

It runs for cross-tab changes too, so both paths agree on what "applying stored state" means. Return null to discard the payload and keep what the store already has; a hook that throws does the same and reports through OnError, because applying a half-merged state would be worse than starting from defaults.

BlexStatePersistor

Resolve it from DI when you need to control persistence directly.

Member Description
Task StartAsync(CancellationToken) Rehydrates every persistent store, then starts listening. Safe to call again - storage errors (such as interop being unavailable during prerender) propagate so the host can retry.
Task FlushAsync() Forces any debounced write now and awaits every queued write. Useful before navigating away, and in tests.
Task RehydrateAsync(CancellationToken) Applies stored state to every persistent store now. Pairs with SkipHydration; the restore itself is not an action, so it does not trigger a write back.
Task ClearAsync(CancellationToken) Removes every persistent store's saved slice from storage.
ValueTask DisposeAsync() Stops listening and flushes what is pending.
bool HasHydrated Whether rehydration has completed at least once - i.e. whether the stores hold stored state or declared defaults.
event Action? Hydrated Raised when hydration completes. Re-render from it to swap a skeleton for real data.
Task WhenHydratedAsync() Completes once hydration has finished; already completed when HasHydrated is true. Released on disposal so a persistor torn down before it hydrated leaves nobody awaiting forever.

Only registered stores rehydrate

StartAsync walks the manager's registry. <BlexProvider> registers everything before calling it, so in Blazor this is automatic. In a host without the provider, resolve every store from DI first - resolution is what registers them.

Custom storage

The core package ships only the abstraction; Bit.Blex.Blazor supplies the browser implementation. Anything else - a file, IndexedDB, a server round-trip - is one small class.

C#
public sealed class FileStorage(string directory) : IBlexStorage
{
    private string Path(string key) => System.IO.Path.Combine(directory, key + ".json");

    public async ValueTask<string?> GetAsync(string key, CancellationToken ct = default)
        => File.Exists(Path(key)) ? await File.ReadAllTextAsync(Path(key), ct) : null;

    public async ValueTask SetAsync(string key, string value, CancellationToken ct = default)
        => await File.WriteAllTextAsync(Path(key), value, ct);

    public ValueTask RemoveAsync(string key, CancellationToken ct = default)
    {
        File.Delete(Path(key));
        return ValueTask.CompletedTask;
    }
}

// Program.cs
services.AddSingleton<IBlexStorage>(new FileStorage("./state"));
services.AddBitBlexPersistence();

A storage that can tell when something else wrote to it - a file watcher, a server push, another tab - can also implement IBlexExternalStorageChange to opt into SyncAcrossTabs. Raise the event only for changes you did not make yourself; echoing your own writes back would spin the two sides against each other.

Reporting external changes
public sealed class FileStorage : IBlexStorage, IBlexExternalStorageChange
{
    private FileSystemWatcher? _watcher;

    public event Action<string?>? ExternalChange;

    public ValueTask StartWatchingAsync(CancellationToken ct = default)
    {
        _watcher ??= new FileSystemWatcher(_directory, "*.json") { EnableRaisingEvents = true };
        _watcher.Changed += (_, e) =>
        {
            // Skip anything this instance just wrote, or the two sides will ping-pong.
            if (!_ownWrites.Remove(e.FullPath))
                ExternalChange?.Invoke(Path.GetFileNameWithoutExtension(e.Name));
        };

        return ValueTask.CompletedTask;
    }

    // ...Get/Set/Remove as above.
}

Prerendering

<BlexProvider> also bridges to Blazor's PersistentComponentState. State produced during prerendering is handed to the interactive render instead of being recomputed, which removes the "double render" flicker. That is separate from durable persistence: it applies to every store, not just persisted ones, and only spans a single page load. Opt out with PersistComponentState="false".

Under Blazor Server, browser storage is unreachable during prerendering because JS interop is not available yet. Rather than crashing startup, the provider retries hydration on first render, once the circuit is interactive.

What you can rely on

  • Corrupt data never breaks startup. An unreadable payload is reported through OnError, discarded and removed from storage.
  • Restores are written back. Undo/redo and DevTools time-travel save the restored state, so a reload cannot resurrect the pre-restore state.
  • Writes are ordered. Saves are chained in dispatch order, so a stale payload can never overwrite a newer one.
  • Rehydration does not echo. Saving is suspended while StartAsync restores, so loading state does not immediately rewrite it.
  • Only persisted stores are written. A store without Persist = true is ignored entirely.
  • A failed write is reported, not swallowed. Quota exhaustion, private mode or storage being disabled reaches OnError with source "persistence" - the app can tell the user their changes are not being saved rather than pretending they were.

Renaming a store strands its data

The storage key is KeyPrefix + store.Name. Change Name and the old payload is orphaned - the store silently starts from defaults. Treat a persisted store's name as part of its contract, and use Version plus Migrate when the shape has to change.

An unhandled error has occurred. Reload 🗙