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.
[BlexStore(Name = "settings", Persist = true)]
public partial class SettingsStore
{
[BlexState] private string _accent = "violet";
[BlexState] private Density _density = Density.Comfortable;
}// 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. |
Change something, then reload the browser. The values come back - and so does the raw payload below, read straight out of localStorage.
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.
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.
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.
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.
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.
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.
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.
@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;
}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.
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.
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.
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.
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
StartAsyncrestores, so loading state does not immediately rewrite it. - Only persisted stores are written. A store without
Persist = trueis ignored entirely. - A failed write is reported, not swallowed. Quota exhaustion, private mode or storage being disabled reaches
OnErrorwith 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.