Plugins ​
Plugins let you intercept wizard transitions and lifecycle events globally, across every step — ideal for analytics, logging, error reporting, and auto-save. A plugin is a plain object implementing the WizardPlugin interface; you register instances on the machine.
onDataChange(prevData, nextData, changedFields)is part of the plugin interface (WIZ-010). It fires after any data mutation (updateField/updateData/setData) that changes at least one top-level field, withDeepReadonlydata payloads. It is fire-and-forget (may be async) and isolated — a throw or rejection is routed toonErrorwithphase: "data".
The WizardPlugin Interface ​
import type { WizardPlugin } from "@gooonzick/wizard-core";
interface WizardPlugin<TData = unknown> {
name: string; // unique; used by removePlugin
onInit?(machine: WizardMachineReadonly<TData>): void | Promise<void>;
/** Return `false` to veto the transition (silent cancel, no error thrown). */
beforeTransition?(
e: TransitionEvent<TData>,
): boolean | undefined | Promise<boolean | undefined>;
afterTransition?(e: TransitionEvent<TData>): void | Promise<void>;
onError?(
error: WizardError | Error,
ctx: ErrorContext<TData>,
): void | Promise<void>;
onComplete?(data: DeepReadonly<TData>): void | Promise<void>;
onReset?(): void | Promise<void>;
/** Fired after a data mutation that changed at least one top-level field. */
onDataChange?(
prevData: DeepReadonly<TData>,
nextData: DeepReadonly<TData>,
changedFields: readonly (keyof TData)[],
): void | Promise<void>;
destroy?(): void | Promise<void>;
}Hook payloads (data, snapshot, currentStep) are typed DeepReadonly<T>: TypeScript prevents mutation at compile time. These are the machine's live references — they are never cloned at runtime.
Supporting Types ​
/** Payload passed to beforeTransition / afterTransition. */
interface TransitionEvent<TData> {
type: "next" | "previous" | "goTo";
fromStepId: StepId;
toStepId: StepId;
data: DeepReadonly<TData>;
timestamp: number;
}
/** Context passed to a plugin's onError hook. */
interface ErrorContext<TData> {
stepId: StepId;
phase: "validation" | "transition" | "lifecycle" | "submit" | "data" | "state" | "load";
data: DeepReadonly<TData>;
}
/** Read-only machine view passed to onInit. */
interface WizardMachineReadonly<TData> {
readonly snapshot: DeepReadonly<WizardState<TData>>;
readonly currentStep: DeepReadonly<WizardStepDefinition<TData>>;
getStepStatus(stepId: StepId): StepStatus;
}Registering Plugins ​
Machine-level API ​
// Register at construction time (5th positional argument):
const machine = new WizardMachine(definition, context, initialData, events, [
myPlugin,
anotherPlugin,
]);
// Register after construction (chainable; throws WizardConfigurationError on duplicate name):
machine.use(analyticsPlugin).use(loggingPlugin);
// Remove by name (awaits that plugin's destroy(), then drops it):
await machine.removePlugin("analytics");
// Tear down all plugins (reverse registration order):
await machine.destroy();React ​
Pass the plugins option to useWizard or <WizardProvider>. The array is read once at machine creation and is not reactive — define it outside the render function or wrap it in useMemo.
import { createLoggingPlugin } from "@gooonzick/wizard-core";
// Define outside the component (or useMemo):
const plugins = [createLoggingPlugin({ level: "debug" })];
function MyWizard() {
const { state, navigation } = useWizard({
definition,
initialData,
plugins,
});
// ...
}Plugins are automatically destroyed (machine.destroy()) when the component unmounts.
With WizardProvider ​
const plugins = [createLoggingPlugin()];
function App() {
return (
<WizardProvider definition={definition} initialData={initialData} plugins={plugins}>
<MyWizardSteps />
</WizardProvider>
);
}Vue ​
Pass the plugins option to useWizard or <WizardProvider>. Captured once at composable setup — not reactive.
import { createLoggingPlugin } from "@gooonzick/wizard-core";
// Define outside setup or as a module-level constant:
const plugins = [createLoggingPlugin({ level: "info" })];
export default defineComponent({
setup() {
const { state, navigation } = useWizard({
definition,
initialData,
plugins,
});
return { state, navigation };
},
});Plugins are automatically destroyed via onScopeDispose when the component scope is disposed.
Hook Reference ​
| Hook | When it fires | Can veto? | Error handling |
|---|---|---|---|
onInit | After machine state is seeded (fire-and-forget) | No | Isolated — logged, does not throw to callers |
beforeTransition | Before every goNext / goPrevious / goTo (incl. skipLifecycle, and the deprecated goBack / goToStep aliases) | Yes — return false | Sequential — a throw aborts the transition and rethrows to the caller (goNext/goPrevious/goTo reject); reported once via onError with phase "transition" |
afterTransition | After the transition succeeds | No | Isolated |
onError | When any hook or validation throws | No | Single reporter — fires exactly once per failure |
onComplete | When the wizard completes — after the committed isCompleted state change, before events.onComplete. All plugins are invoked synchronously in registration order; async work is not awaited between plugins | No | Isolated (sync throws and rejections each reported, phase "lifecycle") |
onReset | When the wizard resets or cancels | No | Isolated |
destroy | When the machine or plugin is torn down | No | Isolated |
Firing Order ​
When multiple plugins are registered, hooks fire in registration order (the order passed to the constructor array or the order of use() calls). The exception is destroy, which fires in reverse registration order for symmetric teardown.
For a single transition with two plugins A and B:
A.beforeTransition → B.beforeTransition
↓ (if neither returned false)
[transition executes]
↓
A.afterTransition → B.afterTransitionOn completion (after definition.onComplete resolves):
[isCompleted + final step "completed" committed] → onStateChange
↓
A.onComplete → B.onComplete (all invoked synchronously, no await in between)
↓
events.onComplete (safe to destroy()/unmount here)The skipLifecycle Rule ​
before/afterTransition fire on all navigation calls — including goTo with skipLifecycle: true. The skipLifecycle flag controls only step-level lifecycle hooks (onEnter / onLeave), not plugin hooks. Plugins always observe every navigation event.
Veto Semantics ​
If beforeTransition returns false, the transition is silently cancelled — no error is thrown, no afterTransition fires. A veto is a clean no-op: the wizard's current step, navigation history, and all step statuses are left completely unchanged (the veto is checked before any onLeave, history, or status mutation, so nothing is partially applied). This holds for every navigation method — goNext, goTo, goPrevious, and goBack. Note that the navigation method still resolves normally: await goTo(...) (and goNext/goPrevious/goBack) returns Promise<void> and resolves rather than rejects on a veto, so callers awaiting the call will not see a rejection.
A veto guards navigation, not prior side effects. beforeTransition is a navigation gate, not a pre-onSubmit gate. On goNext it fires after the current step's validate() and onSubmit have already run; on goTo it fires after the current step has been validated (unless you pass skipValidation: true). So while a veto reliably keeps the wizard on the current step (history, current step, and statuses stay put), it does not undo a validate() pass or an onSubmit that already executed. Put logic that must run before submit into the step's own onSubmit/validation, and use beforeTransition for cross-cutting navigation guards.
Error Semantics — Exactly Once ​
Each error is reported to plugins exactly once regardless of where it originates. When a thrown error is first caught (e.g., inside a validator), onError fires immediately. The calling method (e.g., goNext) does not re-report the same error so your plugin's onError will not be called twice for the same failure.
Plugin hook errors themselves are isolated: if your plugin's onComplete throws, the error is routed through onError at phase "lifecycle" and does not propagate to the calling code.
A throwing onStateChange subscriber (the machine-level event, or a framework binding's listener) is isolated the same way: the error is routed through onError at phase "state" and the in-flight operation (e.g. goNext(), updateField) still completes.
Error Phases ​
ErrorContext.phase tells onError where a failure originated:
| Phase | Origin |
|---|---|
"validation" | A step validator threw, or returned an invalid result during goNext() / goTo() / submit() |
"transition" | The default for navigation: a resolver, guard or beforeTransition failure, an onLeave / onEnter throw, a plugin afterTransition throw, guard / progress resolution errors, and an onSubmit (or definition.onComplete / events.onComplete) throw inside goNext() |
"lifecycle" | A plugin onInit / onComplete / onReset / destroy failure, an error from cancel() handlers (definition.onCancel / events.onCancel), or the initial step's onEnter |
"submit" | onSubmit (or a completion handler) threw inside submit() — only submit() uses this phase; the same throw inside goNext() is "transition" |
"data" | An onDataChange handler or a watchField callback threw (plugin onDataChange too) |
"state" | An onStateChange subscriber threw |
"load" | A lazy step failed to load (WIZ-013) |
A failed lazy-step load is reported once (with a WizardStepLoadError whose message is Failed to load step "<id>": <cause message> and whose native cause is the original failure) even when several operations await the same failed attempt; a retry is a new attempt and is reported again. Plugins with an exhaustive switch on phase need a "load" case.
Re-entrancy / Busy Guard ​
While any navigation (goNext, goPrevious, goTo) is in progress, the machine holds a busy lock. Attempting to call another navigation method from inside a plugin hook (e.g., calling goNext() inside afterTransition) throws a WizardNavigationError with reason: "busy". Design plugins to be pure observers rather than initiators of navigation.
onInit Fire-and-Forget ​
onInit is dispatched fire-and-forget: the machine does not await it before the first user interaction. Plugins must not assume onInit has fully completed before their first beforeTransition fires.
Import Paths ​
All plugin types, createLoggingPlugin, createAnalyticsPlugin, createPersistencePlugin and the built-in storage adapters are available from both the main barrel and a dedicated subpath:
// Main barrel — works for most cases:
import {
createLoggingPlugin,
createAnalyticsPlugin,
createPersistencePlugin,
localStorageAdapter,
sessionStorageAdapter,
} from "@gooonzick/wizard-core";
import type { WizardPlugin, TransitionEvent, ErrorContext } from "@gooonzick/wizard-core";
// Dedicated subpath — useful for tree-shaking or plugin-only bundles:
import {
createLoggingPlugin,
createAnalyticsPlugin,
createPersistencePlugin,
localStorageAdapter,
sessionStorageAdapter,
} from "@gooonzick/wizard-core/plugins";
import type {
WizardPlugin,
TransitionEvent,
ErrorContext,
WizardMachineReadonly,
DeepReadonly,
AnalyticsReport,
BacktrackEntry,
AnalyticsPluginConfig,
AnalyticsPlugin,
PersistedWizardSnapshot,
PersistencePlugin,
PersistencePluginConfig,
PersistenceRestoreOutcome,
PersistenceSkipReason,
WizardPersistenceAdapter,
StorageLike,
WebStorageAdapterOptions,
} from "@gooonzick/wizard-core/plugins";Built-in Plugin: createLoggingPlugin ​
The logging plugin is a reference implementation that logs every hook to the console (or a custom logger). It never vetoes and never throws.
import { createLoggingPlugin } from "@gooonzick/wizard-core";
const logger = createLoggingPlugin({
level: "debug", // "debug" | "info" | "warn" — default: "debug"
logger: console, // any { log, warn, debug } object — default: console
});Log levels:
"debug"— logs everything vialogger.debug"info"— logs vialogger.log, suppresses verbose lines"warn"— only logs errors vialogger.warn, suppresses all verbose output
React example:
const plugins = [
createLoggingPlugin<MyData>({ level: "info" }),
];
function MyWizard() {
const { state } = useWizard({ definition, initialData, plugins });
// ...
}Built-in Plugin: createAnalyticsPlugin ​
The analytics plugin is a second built-in. It automatically times each step, counts backward navigations ("backtracks"), records drop-off on teardown, and fires optional callbacks. Unlike createLoggingPlugin, the returned instance exposes a synchronous getReport() method for reading aggregated metrics at any time. It never vetoes.
import { createAnalyticsPlugin } from "@gooonzick/wizard-core";
// or: import { createAnalyticsPlugin } from "@gooonzick/wizard-core/plugins";
const analytics = createAnalyticsPlugin<SignupData>({
onStepView: (stepId, data) => track("step_view", { stepId }),
onStepComplete: (stepId, durationMs) => track("step_complete", { stepId, durationMs }),
onBacktrack: (from, to) => track("backtrack", { from, to }),
onWizardComplete: (data, totalDurationMs) => track("wizard_complete", { totalDurationMs }),
onDropOff: (stepId, durationMs) => track("drop_off", { stepId, durationMs }),
// now: () => Date.now(), // injectable clock; defaults to Date.now
});
machine.use(analytics);
// Read aggregates at any time — includes the current step's live open-visit time:
const report = analytics.getReport();Config ​
All callbacks are optional; pass only what you need.
| Option | Signature | When it fires |
|---|---|---|
onStepView | (stepId, data) => void | On init and on every step entered (including backtracks). |
onStepComplete | (stepId, durationMs) => void | When a step is left — on each transition for the departing step, and on completion for the terminal step. |
onBacktrack | (fromStepId, toStepId) => void | On a previous transition, or a goTo targeting an already-viewed step. |
onWizardComplete | (data, totalDurationMs) => void | When the wizard completes. |
onDropOff | (stepId, durationMs) => void | On teardown (destroy) only if the wizard never completed. |
now | () => number | Injectable clock used for all timing. Defaults to Date.now. |
getReport() ​
analytics.getReport() returns a snapshot of type AnalyticsReport:
interface AnalyticsReport {
startedAt: number; // now() at session start (onInit / first hook / last reset)
stepTimings: Record<StepId, number>; // accumulated ms per step, INCLUDING the current live visit
backtrackCount: number; // number of backward navigations this session
backtrackHistory: BacktrackEntry[]; // ordered list of { from, to, at }
currentStep: StepId | null; // most recently entered step, or null before any step
completed: boolean; // true once onWizardComplete fired (reset clears it)
totalDuration: number; // completed ? completedAt - startedAt : now() - startedAt
}
interface BacktrackEntry {
from: StepId;
to: StepId;
at: number;
}Semantics ​
- Step timers. Each step's open visit accumulates elapsed time. A step's timer closes on
afterTransition(when you leave it); the final step's timer closes inonComplete.getReport()folds the current step's still-open visit intostepTimingsand intototalDuration, so metrics are always live. - Backtracks. A backtrack is any
previoustransition, or agoToto a step you have already visited this session. - Drop-off.
onDropOfffires fromdestroy()only if the wizard was never completed — completed wizards never drop off, even when the app unmounts or callsdestroy()from insideevents.onComplete(pluginonCompletehooks run first, anddestroy()also checks the machine'ssnapshot.isCompleted). In React/Vue this happens automatically on unmount. - Injectable clock. All durations use
config.now(defaultDate.now), not the transitiontimestamp, so tests can inject a deterministic clock. - Throw-safe. The plugin updates its internal bookkeeping before invoking your callbacks, so a throwing callback can never corrupt the report.
- Reset. Resetting the wizard restarts the analytics session in place (timings, backtracks, and
completedare cleared and the initial step's timer restarts). Reset does not re-emitonStepView.
React example:
import { useMemo } from "react";
import { createAnalyticsPlugin } from "@gooonzick/wizard-core";
function MyWizard() {
const analytics = useMemo(
() => createAnalyticsPlugin<MyData>({
onStepComplete: (stepId, ms) => console.log(stepId, "took", ms, "ms"),
}),
[],
);
const plugins = useMemo(() => [analytics], [analytics]);
const { state } = useWizard({ definition, initialData, plugins });
// Read live aggregates whenever you need them:
const onShowReport = () => console.log(analytics.getReport());
// ...
}Built-in Plugin: createPersistencePlugin ​
The persistence plugin keeps the wizard's serialized state in a storage backend so a page reload does not lose progress. It restores in onInit, saves debounced on data changes and immediately after every committed transition, and clears the record when the wizard completes or is reset.
import {
createPersistencePlugin,
localStorageAdapter,
} from "@gooonzick/wizard-core";
machine.use(
createPersistencePlugin<MyData>({
adapter: localStorageAdapter("wizard:signup"),
debounceMs: 300,
restoreOnInit: true,
}),
);Config ​
| Option | Type | Default | Description |
|---|---|---|---|
adapter | WizardPersistenceAdapter<TData> | required | Storage backend. |
name | string | "persistence" | Plugin name; must be unique per machine. |
restoreOnInit | boolean | true | Read and apply a stored snapshot in onInit. |
debounceMs | number | 300 | Trailing-edge debounce for data-change writes. 0 (or negative) writes on the next microtask. |
version | number | 1 | App-controlled schema version written into / checked against the envelope. |
maxAgeMs | number | undefined | Discard snapshots older than this many ms. Unset means never expire. |
saveOnTransition | boolean | true | Save after every committed transition. |
saveOnDataChange | boolean | true | Save (debounced) after every data change. |
clearOnComplete | boolean | true | Clear on completion, and refuse to restore a completed snapshot. |
clearOnReset | boolean | true | Clear on reset() / cancel(). |
flushOnUnload | boolean | false | Register a pagehide listener that flushes pending writes. |
beforeSave | (state) => state | null | — | Last chance to redact/transform before writing. Return null to skip the write. |
onRestored | (state) => void | — | A snapshot was successfully applied. |
onRestoreSkipped | (reason) => void | — | A snapshot was intentionally not applied. |
onRestoreError | (error, raw) => void | — | Load / parse / validate / restore failed. raw is the untrusted adapter value. |
onSaveError | (error) => void | — | A save() or clear() failed. |
The returned plugin is a WizardPlugin plus three members:
plugin.ready; // Promise<PersistenceRestoreOutcome<TData>> — latest onInit's outcome, never rejects
await plugin.flush(); // cancel the debounce and drain any pending write
await plugin.clear(); // drop any pending save and clear the stored recordready resolves to { status: "restored", state }, { status: "skipped", reason } or { status: "failed", error }. Skip reasons are "disabled", "unsupported", "empty", "version-mismatch", "expired", "completed", "stale" and "destroyed".
ready tracks the latest onInit: when the same plugin instance is initialised again after its previous attempt settled (a React StrictMode probe, or a remount that re-uses a hoisted plugin after destroy() settled { status: "skipped", reason: "destroyed" }), it is re-armed with a fresh promise. Read plugin.ready after the WizardMachine constructor / use() call; a reference captured earlier may still report the previous machine. A superseded init's late load never settles the current ready and never releases the current init's write suppression.
Adapters ​
An adapter is any object implementing the async-first WizardPersistenceAdapter<TData> contract — every method may return a value or a promise, so localStorage, IndexedDB and server backends share one interface:
interface WizardPersistenceAdapter<TData> {
load(): PersistedWizardSnapshot<TData> | null | Promise<PersistedWizardSnapshot<TData> | null>;
save(snapshot: PersistedWizardSnapshot<TData>): void | Promise<void>;
clear(): void | Promise<void>;
}The value handed to the adapter is an envelope, not the raw machine state:
interface PersistedWizardSnapshot<TData> {
envelope: 1; // library-owned envelope format
version: number; // your `config.version`
savedAt: number; // Date.now() at write time
state: WizardSerializedState<TData>; // machine.serialize() output
}Two adapters ship with the core package:
localStorageAdapter<TData>(key: string, options?: { storage?: StorageLike }): WizardPersistenceAdapter<TData>;
sessionStorageAdapter<TData>(key: string, options?: { storage?: StorageLike }): WizardPersistenceAdapter<TData>;Both resolve the storage global lazily inside each call and never touch it at import time, so they are safe to import on the server. When storage is unavailable (SSR, private mode, a hardened browser) load() returns null and save() / clear() are silent no-ops — save() runs on every keystroke, so throwing there would be pure noise. Pass options.storage to inject a fake in tests.
Writing your own is just the three methods above. load() returns null when nothing is stored; throwing/rejecting from any method is reported through onRestoreError / onSaveError and never reaches the machine's error channel.
Semantics ​
- Restore ordering. With a synchronous adapter (the built-ins) the snapshot is applied inside
onInit, i.e. beforemachine.use(...)— or theWizardMachineconstructor — returns, so the first snapshot the UI renders already contains the restored state. With an asynchronous adapter there is no ordering guarantee:await plugin.readybefore enabling interaction. - A late async load is discarded, never force-applied, when the plugin was destroyed or superseded, when the wizard already moved on (any transition, data change, complete or reset), when
machine.isBusyis true, or when the snapshot is a completed one. Overwriting live user input is worse than losing the snapshot; callmachine.restore(...)yourself if you want different behaviour. While the load is in flight, writes are suppressed and drained as soon as it settles, so a save can never clobber a record before it has been read. - Which hooks write.
afterTransitionsaves immediately (and absorbs a pending debounced write);onDataChangesaves debounced;onCompleteclears (or saves whenclearOnComplete: false) and then goes inert until the next reset;onReset— which also coverscancel()— drops the pending payload first, so a write scheduled just before a cancel can never land with pre-cancel data.onInitnever saves. The plugin never vetoes a transition and never adds latency to navigation: no hook awaits the storage write. - Debounce. Trailing edge only: N rapid
updateFieldcalls inside the window produce one write. The payload is built at flush time, not at schedule time, so the stored value is always the newest state. Writes are coalesced into a single-slot queue with a serialized tail — adapter calls never overlap and the last scheduled operation wins. - Flush on destroy.
destroy()flushes only if a write is pending (never speculatively) and returns the queue promise, soawait machine.destroy()completes the final write. React/Vue do not await unmount cleanup, so an asynchronous adapter is best-effort there; a synchronous one always completes.flushOnUnload: trueadditionally registers apagehidelistener — again, only synchronous adapters can realistically finish during unload. Forvisibilitychange/beforeunload, wire them yourself toflush(). - Versioning and TTL. Bump
config.versionon a deploy that changesTData's shape or the step graph: old records are discarded ("version-mismatch") and cleared.maxAgeMsexpires stale records ("expired"). The innerWizardSerializedState.versionis the library format version and is enforced bymachine.restore(). - Failures are swallowed. A corrupt, stale or unknown-step record is reported via
onRestoreError, the record is cleared so it cannot re-fail on every mount, and the wizard starts clean.onInitnever throws and never returns a promise, so persistence failures never reach the machine'sonErroror other plugins'onError. SupplyonRestoreError/onSaveError; without them the plugin logs at most oneconsole.warnper category. - Key collisions are yours to avoid. The plugin cannot auto-namespace (the read-only facade does not expose the definition id). Two wizards sharing a key destroy each other's progress, so use a per-wizard key:
localStorageAdapter(`wizard:${definition.id}`). - Persisted data must be JSON-safe.
machine.serialize()usesstructuredClone, but the web-storage adapters useJSON.stringify: aDateround-trips as a string,undefinedvalues disappear and a cycle throws. Normalise inbeforeSave(which is also the right place to strip passwords and other PII before they reachlocalStorage).
React example:
import { useMemo } from "react";
import {
createPersistencePlugin,
localStorageAdapter,
} from "@gooonzick/wizard-core";
function MyWizard() {
const plugins = useMemo(
() => [
createPersistencePlugin<MyData>({
adapter: localStorageAdapter<MyData>("wizard:signup"),
debounceMs: 300,
beforeSave: (state) => ({
...state,
data: { ...state.data, password: "" }, // never persist secrets
}),
onRestoreError: (err) => console.warn("could not restore", err),
}),
],
[],
);
const { state } = useWizard({ definition, initialData, plugins });
// ...
}Writing Your Own Plugin ​
A plugin is a plain object with a name and any subset of the optional hooks. (For built-in analytics use createAnalyticsPlugin above; the example below shows how you would build a custom tracking plugin from scratch.) Here is a minimal tracking plugin:
import type { WizardPlugin, TransitionEvent, DeepReadonly } from "@gooonzick/wizard-core";
interface TrackingConfig {
track: (event: string, props?: Record<string, unknown>) => void;
}
function createTrackingPlugin<TData>(
config: TrackingConfig,
): WizardPlugin<TData> {
return {
name: "tracking",
onInit(machine) {
config.track("wizard_init", {
step: machine.snapshot.currentStepId,
});
},
afterTransition(e: TransitionEvent<TData>) {
config.track("wizard_step_change", {
type: e.type,
from: e.fromStepId,
to: e.toStepId,
});
},
onComplete(data: DeepReadonly<TData>) {
config.track("wizard_complete");
},
onError(error, ctx) {
config.track("wizard_error", {
phase: ctx.phase,
step: ctx.stepId,
message: error.message,
});
},
destroy() {
// Release any subscriptions or timers here.
},
};
}Register it:
const machine = new WizardMachine(definition, context, initialData, events, [
createTrackingPlugin({ track: myAnalyticsLib.track }),
createLoggingPlugin({ level: "warn" }),
]);Or with React:
const plugins = useMemo(
() => [createTrackingPlugin({ track: myAnalyticsLib.track })],
[],
);
const { state } = useWizard({ definition, initialData, plugins });Plugin Checklist ​
- Give your plugin a unique
name— duplicate names throwWizardConfigurationError. - All hooks are optional; implement only what you need.
- Never mutate the
data,snapshot, orcurrentSteppayloads — they are typedDeepReadonly<T>. - Do not call navigation methods (
goNext,goTo, etc.) from inside a hook — the machine is busy and the call will throw. - Clean up timers, subscriptions, and external state in
destroy. - If your plugin is async, make sure rejected promises do not escape — the machine isolates hook errors but a top-level unhandled rejection still reaches the runtime.