State lifecycle
DevTools & time travel
Every action streams into the Redux DevTools extension with its payload and the resulting state tree - and the slider rewinds your app.
Getting connected
- Install the Redux DevTools browser extension.
- Run the app with
<BlexProvider>in place andEnableDevToolsleft at its default. - Open the browser devtools and switch to the Redux tab. The instance is named after
options.DevToolsName. - Interact with the app. Every action arrives with its argument payload and the state that followed it.
- Drag the slider, or use the jump buttons, to rewind the application live.
builder.Services.AddBitBlexServices(options =>
{
options.DevToolsName = "My App"; // shown in the extension's instance dropdown
});This very page is connected
If you have the extension installed, open it now and look for the instance named Blex Docs. Then press the buttons below and jump around the timeline - the demos on this site will rewind with it.
Dispatch a few actions, then rewind them from the extension's timeline.
In the extension, that token reads <redacted> - this site registers
options.RedactDevToolsKeys("Token").
What travels over the bridge
On connect, the manager sends the current tree as the initial state. After that, each recorded action sends an action object and the resulting tree:
// init, on connect
{ "counter": { "Count": 0, "Step": 1 }, "settings": { "Accent": "violet" } }
// send, per action
action: { "type": "counter/SetStep", "payload": { "step": 5 } }
state: { "counter": { "Count": 0, "Step": 5 }, "settings": { "Accent": "violet" } }
The state tree is keyed by store name - counter, settings and so on
- which is exactly what BlexManager.CaptureGlobalState() produces.
Messages Blex handles
Time-travel is driven by the extension sending messages back. HandleDevToolsMessage
interprets these:
| Message | Effect |
|---|---|
JUMP_TO_STATE / JUMP_TO_ACTION | Applies the state the extension supplies - the slider and the jump buttons. |
ROLLBACK | Reverts to the last committed snapshot, or to the state supplied with the message when nothing has been committed. |
COMMIT | Takes the current state as the new baseline and re-initializes the monitor. |
RESET | Restores the state captured when DevTools first connected, then re-initializes the monitor. |
IMPORT_STATE | Applies the last computed state from an imported session. |
PAUSE_RECORDING | Stops streaming actions to the monitor. The app keeps running: actions still apply, and still reach middleware and subscribers. |
LOCK_CHANGES | Freezes the app - every action is vetoed until it is unlocked. Time-travel restores keep working, which is the point. |
Applying a snapshot goes through RestoreGlobalState, so it never records new
actions - the timeline you are scrubbing does not grow while you scrub it. A store whose
slice fails to deserialize is reported to OnError and
skipped; the rest still restore.
Pause and lock
The monitor's two recording controls are different in kind, and the difference matters when you are hunting a bug. Pause recording is about the timeline: the app carries on exactly as before, it just stops filling the monitor with noise while you set up the state you actually want to inspect. Lock changes is about the app: every action is refused, so nothing can move underneath you while you scrub.
Both are readable from the manager, so the UI can tell the user why its buttons appear dead:
// Paused: actions apply and are observed, they are just not streamed to the monitor.
if (manager.IsDevToolsPaused)
_hint = "DevTools recording is paused.";
// Locked: every action is vetoed, exactly as a UseFilter returning false would.
// Restores are deliberately not gated, so the slider still moves.
if (manager.AreChangesLocked)
_hint = "The monitor has locked changes - unlock it to interact.";Open the Redux DevTools panel and press Pause recording or Lock changes, then use the counter. This card reads the manager's own flags.
Paused, the counter still moves but the monitor stays quiet. Locked, the counter stops moving - and the slider still works.
Disconnecting always unlocks
Closing the monitor, or tearing down a circuit, while "Lock changes" was on would otherwise leave the application permanently frozen with no remaining way to unfreeze it. Disconnecting the bridge clears both flags.
Redacting sensitive state
The state tree is handed to a browser extension. Anything in it is visible to that extension. Blex gives you two hooks plus a shorthand.
builder.Services.AddBitBlexServices(options =>
{
// Take full control of the state tree...
options.DevToolsStateSanitizer = state =>
{
var clone = (JsonObject)state.DeepClone();
if (clone["user"] is JsonObject user)
user["email"] = "<hidden>";
return clone;
};
// ...and redact these keys anywhere in the tree (and in action payloads) on top of it.
// Register redaction last: it wraps the sanitizer above, but an assignment after this
// call would replace it.
options.RedactDevToolsKeys("token", "password", "ssn");
// The action label has its own hook.
options.DevToolsActionSanitizer = label => label.Replace(_userId, "<id>");
});| Member | Description |
|---|---|
options.DevToolsName |
The instance name shown in the extension's dropdown. Default "Blex". |
options.RedactDevToolsKeys(params string[]) |
Shorthand: replaces the named keys, anywhere in the tree, with "<redacted>". Applies to action payloads too. |
options.DevToolsStateSanitizer |
Func<JsonObject, JsonObject>. Full control over the tree before it is sent. Display only. |
options.DevToolsActionSanitizer |
Func<string, string>. Rewrites the action label - useful for stripping ids out of names. |
RedactDevToolsKeys walks the whole tree - objects and arrays - replacing
matching keys with <redacted>, and it is applied to action payloads as well
as state, so an argument named password is redacted too.
It is additive, and it composes. Calling it a second time widens the redaction set
rather than replacing it, so a library and an app can each register their own secrets without
one silently unmasking the other's. A custom DevToolsStateSanitizer assigned
before the call still runs - redaction is applied on top of its output - which lets
you reshape the tree and redact it in the same pipeline.
// A shared setup module redacts what it knows about...
options.RedactDevToolsKeys("token");
// ...and the app adds its own. The set widens; "token" stays redacted.
options.RedactDevToolsKeys("ssn", "dateOfBirth");Assigning the sanitizer last wins
The composition is one-directional: redaction wraps whatever sanitizer was already there.
Assigning DevToolsStateSanitizer after calling
RedactDevToolsKeys replaces redaction outright. Register redaction last.
Redaction and time-travel fight each other
DevTools echoes the state it was shown back to the app when you jump. A redacted
value therefore comes back as the literal string <redacted> and
overwrites the real one. Redact only fields you never need to jump back through - or turn
DevTools off entirely in the environments where the data is real.
Sanitizers fail closed
If your state sanitizer throws, Blex sends an empty tree rather than the
unsanitized one - a bug in redaction cannot leak what it was meant to hide. A throwing
action sanitizer yields <sanitizer-error>. Both are reported through
OnError with source devtools.
Turning it off
For production, disable the connection outright. Nothing is serialized and nothing is sent.
<BlexProvider EnableDevTools="@Env.IsDevelopment()">
<Router ... />
</BlexProvider>Under the hood
The core package has no JS dependency - it only knows the IBlexDevTools
interface. Bit.Blex.Blazor supplies BlexReduxDevToolsConnector, which
loads a small ES module and talks to
window.__REDUX_DEVTOOLS_EXTENSION__.
public interface IBlexDevTools
{
void Init(JsonObject globalState);
void Send(string actionName, JsonObject globalState);
void Send(string actionName, JsonObject globalState, JsonObject? payload)
=> Send(actionName, globalState);
}- If the extension is not installed,
connectreturnsfalseand everything becomes a no-op - no errors, no cost. - Interop calls are dispatched without blocking the dispatch pipeline, but their tasks are still observed so a transient failure cannot surface as an unobserved exception.
- Disconnecting detaches only Blex's own subscription, rather than killing every DevTools instance on the page.
- The bridge advertises exactly what it implements:
jumpandexportyes;dispatch,skipandreorderno.
Connecting a different sink
IBlexDevTools is public, so the manager can feed anything - a server-side
session recorder, a test double, a custom monitor.
public sealed class RecordingDevTools : IBlexDevTools
{
public List<(string Action, JsonObject State)> Frames { get; } = [];
public void Init(JsonObject state) => Frames.Add(("@@INIT", state));
public void Send(string action, JsonObject state) => Frames.Add((action, state));
}
manager.ConnectDevTools(new RecordingDevTools());
// ... and later
manager.DisconnectDevTools();