@marianmeres/wizard
v3.0.0
Published
[](https://www.npmjs.com/package/@marianmeres/wizard) [](https://jsr.io/@marianmeres/wizard) [ - Pre-action hooks (
preNext,prePrevious,preReset) - Global context shared across all steps
- Concurrent-navigation safe — double-clicks and hook re-entry are silently ignored
- Terminal
isDoneflag with double-completion protection - Full TypeScript support with generics
Example usage
import { createWizard } from "@marianmeres/wizard";
interface StepData {
name?: string;
email?: string;
agreed?: boolean;
}
interface Context {
apiUrl: string;
}
const wizard = createWizard<StepData, Context>("registration", {
steps: [
{ label: "Personal Info", data: { name: "", email: "" } },
{
label: "Terms & Conditions",
canGoNext: false, // must be explicitly enabled
preNext: async (data, { update }) => {
if (!data.agreed) throw new Error("You must agree to the terms");
update({ canGoNext: true });
},
prePrevious: async (_data, { update }) => {
update({ canGoNext: false, data: { agreed: false } });
},
},
{ label: "Confirmation" },
],
context: { apiUrl: "https://api.example.com" },
onDone: async ({ steps, context }) => {
const formData = steps.map((s) => s.data);
await fetch(context.apiUrl, {
method: "POST",
body: JSON.stringify(formData),
});
},
});
wizard.subscribe(({ step, steps, inProgress, isDone }) => {
const { label, index, data, canGoNext, canGoPrevious, error, isFirst, isLast } = step;
step.update({ data: { name: "John" } });
step.update({ canGoNext: true });
step.update({ error: null });
step.clearError(); // convenience
step.update({ data: (prev) => ({ ...prev, email: "[email protected]" }) });
});
await wizard.next();
await wizard.next({ name: "John" });
await wizard.previous();
await wizard.reset();
await wizard.goto(2);
await wizard.goto(2, [null, { agreed: true }]);
wizard.allowCanGoNext();
wizard.resetCanGoNext();
wizard.publish();
wizard.context;
wizard.label;API overview
createWizard<TData, TContext>(label, options)
Creates a wizard instance.
Key options:
steps— array of step configurations (minimum 2 required)context— global context object (optional)onDone— callback when completing the last steppreReset— global reset callback (optional)
Navigation methods:
next(data?)— move to next stepprevious()— move to previous step (respectscanGoPrevious)reset()— reset to initial stategoto(index, stepsData?, assert?)— jump to specific step
Store methods:
get()— synchronous snapshotsubscribe(cb)— react to state changespublish()— force a state emission
Utilities:
allowCanGoNext()/resetCanGoNext()— control forward gating flagsstep.update(values)— update data / error / canGoNext / canGoPreviousstep.clearError()— clear step errorresolveLabel(label, locale?)— resolve aLabelto a string
For complete API documentation, see API.md.
Concurrency and hook safety
Navigation is serialized: while next, previous, goto, or reset is in-flight,
any further navigation call — including a call made from inside a hook or onDone — is
silently ignored and returns the current step index.
// Double-click safe — only advances once.
button.onclick = () => wizard.next();
// Inside a hook: silently ignored (does NOT throw, does NOT recurse).
preNext: (async (_data, { wizard }) => {
await wizard.reset(); // no-op
});Deferred navigation
To navigate from a hook, schedule it for the next tick:
preNext: ((_data, { wizard }) => {
setTimeout(() => wizard.reset(), 0);
});isDone and double-completion protection
When next() is invoked on the last step and onDone completes successfully, isDone
becomes true. Subsequent next() calls are no-ops until previous() or reset() is
invoked.
wizard.subscribe(({ isDone }) => {
if (isDone) showSuccessScreen();
});
await wizard.next(); // triggers onDone; isDone → true
await wizard.next(); // no-op; onDone NOT re-invoked
await wizard.reset(); // isDone → falseIf onDone throws, isDone stays false, allowing the user to retry.
Error handling
Errors thrown from hooks are captured on step.error:
| Hook | Captured on | Blocks navigation? |
| ----------------- | ------------ | ------------------------------------------- |
| preNext | Current step | Yes (forward) |
| prePrevious | Current step | No (back still proceeds) |
| preReset | — | No (swallowed, logged if logger provided) |
| onDone | Last step | Yes (stays on last step, isDone not set) |
| Global preReset | — | No (swallowed) |
Migration from v2.x → v3.x
v3 is a bug-fix / design-cleanup release. The API surface is mostly unchanged but several behaviors changed. Review each item:
Behavior changes (BC)
Navigation from inside hooks no longer throws
TypeError— calls are silently ignored. UsesetTimeout(...)if you need deferred navigation. If you were catchingTypeErrorfrom hook-reentry, remove the catch.next()on the last step no longer re-runsonDoneafter a successful completion.isDonetracks completion. Callreset()orprevious()to re-enable forward navigation.step.update({ data: X })always publishes — the reference-equality short-circuit was removed. In-place mutation plusupdate({ data: step.data })now publishes correctly. Subscribers should already be idempotent.reset()emits one transition — previously, reset walked through each step index and published every intermediate state. Now subscribers seeinProgress=truethen the final reset state (step 0).previous()respects the newcanGoPreviousflag on the current step (defaulttrue; existing code is unaffected unless explicitly opted in).
New additions
canGoPrevious?: booleanon step config andWizardStepisDone: booleanon the store valueclearError()on stepresolveLabel(label, locale?)helperstep.update({ canGoPrevious })support
Type-surface changes
WizardStepno longer extendsWizardStepConfig. The raw hook properties (preNext,prePrevious,preReset) are not present on the runtime step object. If your code accessed them, switch to configuring them in step config.
License
MIT
