@gooonzick/wizard-solid
v1.12.0
Published
Solid.js integration for the WizardForm framework
Maintainers
Readme
@gooonzick/wizard-solid
Solid.js integration for the Wizard framework — a signal-backed createWizard() with fine-grained reactive getters.
Features
- Signal-backed - every property is a reactive getter; read it in JSX or
createEffectand only that channel is tracked - Atomic updates - state, navigation, validation and loading update in one
batch(), so effects never see a half-applied transition - Two-way binding -
wizard.field('name')returns{ get value, set value }routed throughmachine.updateField - Context -
<WizardProvider wizard={wizard}>+useWizardContext()/hasWizardContext() - Owner-aware lifecycle - destroyed with the owning component or
createRoot; manualdestroy()otherwise - Full Type Safety - TypeScript generics for your data types
Installation
npm install @gooonzick/wizard-solid @gooonzick/wizard-core
# or
pnpm add @gooonzick/wizard-solid @gooonzick/wizard-coreRequires solid-js 1.8 or newer (Solid 1.x). Solid 2.0 is not supported yet.
Quick Start
import { createLinearWizard } from "@gooonzick/wizard-core";
import { createWizard } from "@gooonzick/wizard-solid";
import { Match, Switch } from "solid-js";
type Signup = { name: string; plan: string };
const definition = createLinearWizard<Signup>({
id: "signup",
steps: [
{
id: "personal",
title: "Personal",
validate: async (d) =>
d.name ? { valid: true } : { valid: false, errors: { name: "Required" } },
},
{ id: "plan", title: "Plan" },
],
});
export function Signup() {
const wizard = createWizard({
definition,
initialData: { name: "", plan: "basic" },
onComplete: (data) => console.log("done", data),
});
const name = wizard.field("name");
return (
<form onSubmit={(e) => e.preventDefault()}>
<h2>{wizard.currentStep.meta?.title}</h2>
<Switch>
<Match when={wizard.currentStepId === "personal"}>
<input value={name.value} onInput={(e) => (name.value = e.currentTarget.value)} />
<p>{wizard.validationErrors?.name}</p>
</Match>
<Match when={wizard.currentStepId === "plan"}>
<p>Plan: {wizard.data.plan}</p>
</Match>
</Switch>
<button
type="button"
onClick={() => void wizard.goPrevious().catch(() => {})}
disabled={!wizard.canGoPrevious}
>
Back
</button>
<button
type="button"
onClick={() => void wizard.goNext().catch(() => {})}
disabled={wizard.isNavigating}
>
{/* On the last step goNext() completes the wizard; canGoNext is false there. */}
{wizard.progress.isLastStep ? "Finish" : "Next"}
</button>
</form>
);
}Reactivity rules
- Read properties inside JSX,
createEffectorcreateMemo. Destructuring (const { canGoNext } = wizard) reads once and loses reactivity — same as Solid props. - Tracking is per channel (
state,navigation,validation,loading), not per field. definition,initialData,contextandpluginsare read once. Recreate the wizard to reconfigure.
Things to know
canGoNextandisLastStepare seeded synchronously fromprogress.isLastStep(on creation and on every step change), so they are correct on first render for synchronous graphs; a step whosenextis an async resolver starts as "not last" until the async computation settles.canGoPreviousandavailableStepscome only from that async computation: right after creationcanGoPreviousisfalseandavailableStepsis empty.canGoNextmeans "a next step exists", not "the current step is valid" — validation runs insidegoNext().- Loading flags (
isNavigating,isValidating,isSubmitting) are reference-counted: a flag staystruewhile any operation that set it is still in flight, so a double-clicked Next rejected as busy, or an overlappingvalidate(), no longer clears another operation's flag.isLoadingStep(a lazy step's implementation is loading) mirrors the machine and is not set byactions.preloadStep(stepId), which never rejects (safe to fire and forget; failures are reported by the navigation that needs the step). goNext(),goPrevious(),goTo(),submit()andcancel()reject on failure (for example an invalid step) in addition to reporting toonError—awaitthem in atry, or.catch(() => {})when the UI already rendersvalidationErrors.validate()resolves and exposes the result viaisValid/validationErrors, even on an invalid step — it rejects only if the operation was already aborted (via anAbortSignalpassed in context) before the call; areset()/cancel()while it is in flight does not reject it, it just supersedes the result.validateAll()also resolves with aValidationSummaryeven when steps are invalid (a throwing step validator counts as invalid) — it rejects if the wizard was already aborted, or if a step'senabledguard throws. Areset()/cancel()/restore()/destroy()fired while it's running does not cancel it, but withupdateStatuses: trueits step statuses are no longer written into the (now post-reset) state.
Lifecycle
Created inside a component or createRoot, the wizard is destroyed with its owner (autoDestroy: true by default). Created elsewhere (module scope, a shared store), call await wizard.destroy() yourself.
Errors
Machine errors go to onError. A createEffect that throws while the wizard updates its signals is also reported to onError (unless an <ErrorBoundary> catches it first): the wizard keeps working, but Solid may leave other effects of that update stale — wrap user effects in <ErrorBoundary> or catchError. This covers every signal update, including loading-flag changes and the async navigation recompute. Without an onError, these effect errors and reset() / restore() failures are logged with console.error instead of being dropped.
Documentation
License
MIT
