Vue Integration Guide
This guide covers how to use the useWizard composable to integrate wizards into your Vue 3 application.
Installation
npm install @gooonzick/wizard-core @gooonzick/wizard-vueBasic Usage
The useWizard() composable connects a wizard definition to Vue reactive state and provides everything you need to build a wizard UI.
<script setup lang="ts">
import { useWizard, useWizardField } from "@gooonzick/wizard-vue";
import { createLinearWizard } from "@gooonzick/wizard-core";
type SignupData = {
name: string;
email: string;
};
const wizardDef = createLinearWizard<SignupData>({
id: "signup",
steps: [
{
id: "personal",
title: "Personal Info",
validate: (data) => ({
valid: Boolean(data.name),
errors: data.name ? undefined : { name: "Required" },
}),
},
{
id: "contact",
title: "Contact Info",
validate: (data) => ({
valid: Boolean(data.email),
errors: data.email ? undefined : { email: "Required" },
}),
},
],
onComplete: (data) => {
console.log("Completed!", data);
},
});
const wizard = useWizard<SignupData>({
definition: wizardDef,
initialData: { name: "", email: "" },
onComplete: (data) => {
console.log("Form completed:", data);
},
});
const { state, navigation, validation, loading } = wizard;
const name = useWizardField(wizard, "name");
const email = useWizardField(wizard, "email");
</script>
<template>
<div>
<h2>{{ state.currentStep.value?.meta?.title }}</h2>
<input
v-if="state.currentStepId.value === 'personal'"
v-model="name"
placeholder="Name"
/>
<input
v-if="state.currentStepId.value === 'contact'"
v-model="email"
placeholder="Email"
/>
<div v-if="validation.validationErrors.value" class="errors">
<p
v-for="(error, field) in validation.validationErrors.value"
:key="field"
>
{{ error }}
</p>
</div>
<button
@click="navigation.goPrevious()"
:disabled="!navigation.canGoPrevious.value || loading.isNavigating.value"
>
Previous
</button>
<button
@click="navigation.goNext()"
:disabled="loading.isNavigating.value"
>
<!-- On the last step goNext() completes the wizard; canGoNext is false there. -->
{{ navigation.isLastStep.value ? "Complete" : "Next" }}
</button>
</div>
</template>Use useWizardField() when you want v-model ergonomics without introducing a second reactive source of truth. The binding reads from the wizard state and writes through actions.updateField().
Overloads:
// 1) Pass the useWizard() return value (standalone)
const name = useWizardField(wizard, "name");
// 2) Field-only form inside WizardProvider (uses inject)
const email = useWizardField<{ email: string }, "email">("email");Return type is a writable computed ref (WritableComputedRef<T[K]> / Ref<T[K]>).
Schema Validation
You do not need to manually translate schema issues into wizard errors when your form library uses a Standard Schema compatible validator.
<script setup lang="ts">
import { createLinearWizard, createStandardSchemaValidator } from "@gooonzick/wizard-core";
import * as v from "valibot";
const accountSchema = v.object({
name: v.pipe(v.string(), v.minLength(1, "Name is required")),
email: v.pipe(v.string(), v.email("Email is invalid")),
});
const wizardDef = createLinearWizard({
id: "signup",
steps: [
{
id: "account",
title: "Account",
validate: createStandardSchemaValidator(accountSchema),
},
],
});
</script>Schema issues are mapped into validation.validationErrors.value, so your form UI can render the wizard errors directly.
useWizard Composable API
Options
interface UseWizardOptions<T> {
definition: WizardDefinition<T>;
initialData: T;
context?: WizardContext;
onStateChange?: (state: WizardState<T>) => void;
onStepEnter?: (stepId: string, data: T) => void;
onStepLeave?: (stepId: string, data: T) => void;
onComplete?: (data: T) => void;
onCancel?: (data: T) => void | Promise<void>;
onReset?: () => void;
onError?: (error: Error) => void;
onDataChange?: (prevData: T, nextData: T, changedFields: (keyof T)[]) => void;
/** Reference-stable — read once at machine creation. */
plugins?: WizardPlugin<T>[];
}Return Value
The composable returns an organized object with state grouped into five logical slices:
const { state, validation, navigation, loading, actions } = useWizard({ ... });State Slice
state.currentStepId.value; // Current step ID (ComputedRef)
state.currentStep.value; // Current step definition (ComputedRef)
state.data.value; // Current wizard data (ComputedRef)
state.isCompleted.value; // Has wizard completed? (ComputedRef)
state.stepStatuses.value; // Record<StepId, StepStatus> — status of every step
state.progress.value; // WizardProgress — computed totals, index, percentageValidation Slice
validation.isValid.value; // Is current step valid? (ComputedRef)
validation.validationErrors.value; // Field-level errors (ComputedRef)Navigation Slice
// State (all ComputedRef)
navigation.canGoNext.value; // Can move to next step?
navigation.canGoPrevious.value; // Can move to previous step?
navigation.canGoBack.value; // Can go back via history stack?
navigation.isFirstStep.value; // Is on first step?
navigation.isLastStep.value; // Is on last step?
navigation.visitedSteps.value; // Array of visited step IDs
navigation.availableSteps.value; // Array of currently enabled steps
navigation.stepHistory.value; // Navigation history stack
// Actions
await navigation.goNext(); // Go to next step
await navigation.goPrevious(); // Go to previous step (pops from history)
await navigation.goBack(n); // Go back n steps (deprecated, use goPrevious)
await navigation.goTo(id); // Jump to specific step (validates first)Loading Slice
loading.isValidating.value; // Validation in progress? (ComputedRef)
loading.isSubmitting.value; // Submission in progress? (ComputedRef)
loading.isNavigating.value; // Navigation in progress? (ComputedRef)
loading.isLoadingStep.value; // A lazy step's implementation is loading? (ComputedRef, see Lazy Steps)Actions Slice
// Update data
actions.updateData((data) => ({ ...data, name: "John" }));
actions.setData(completeData);
actions.updateField("name", "John");React to data changes by passing onDataChange to useWizard. It fires after any updateField / updateData / setData that changes a top-level field (after onStateChange; not on reset/restore/navigation):
<script setup lang="ts">
const { actions } = useWizard({
definition,
initialData,
onDataChange: (prev, next, changedFields) => {
if (changedFields.includes("plan")) {
actions.updateField("price", PRICES[next.plan]);
}
},
});
</script>// Validation
await actions.validate(); // Manually validate (result goes to validation slice)
await actions.validateAll(); // Dry-run all enabled steps → ValidationSummary
await actions.validateAll({ updateStatuses: true }); // Also mark invalid steps as "error"
await actions.canSubmit(); // Check if can submit
await actions.submit(); // Submit current step
// Reset / Cancel
actions.reset(); // Rewind to initial step + initial data, fires onReset
actions.reset(newData); // Rewind with replacement data
await actions.cancel(); // Awaits definition.onCancel + onCancel event, then resets
// Persistence
actions.serialize(); // JSON-safe runtime snapshot
actions.restore(savedState); // Re-apply snapshot (throws WizardRestoreError if incompatible)
// (Prefer `createPersistencePlugin` for automatic save/restore — see "Persisting Progress".)
// Lazy steps
actions.preloadStep("documents"); // Prefetch a lazy step's implementation (never rejects)Steps declared with load / .lazy() load their validate and lifecycle hooks on first use; show a spinner with loading.isLoadingStep.value. actions.preloadStep does not set the flag and never rejects (failures are reported by the navigation that needs the step), so it is safe to fire and forget from hover/focus handlers; machine.preloadStep() in core still rejects. See Lazy Steps.
Reset & Cancel
Use actions.reset() to rewind the wizard to its initial step and original data — for example after an error, or to let the user start over.
<button @click="actions.reset()">Start over</button>Use actions.cancel() to abandon the wizard. It awaits the optional definition.onCancel handler (where you can run server-side cleanup, e.g. delete a draft) and then resets.
<script setup lang="ts">
const handleCancel = async () => {
if (!confirm("Discard your progress?")) return;
await actions.cancel();
};
</script>Observe both via the onReset and onCancel event hooks:
useWizard({
definition,
initialData,
onCancel: (data) =>
analytics.track("wizard_cancelled", { email: data.email }),
onReset: () => analytics.track("wizard_reset"),
});Declarative server cleanup via the wizard definition:
createWizard<SignupData>("signup")
// …steps…
.onCancel(async (data) => {
await api.deleteDraft(data.draftId);
})
.build();Common Patterns
Conditional Rendering
<script setup lang="ts">
const { state, actions } = useWizard({ definition, initialData });
</script>
<template>
<div>
<h2>{{ state.currentStep.meta?.title }}</h2>
<PersonalForm
v-if="state.currentStepId.value === 'personal'"
:data="state.data.value"
@update="actions.updateField"
/>
<AddressForm
v-if="state.currentStepId.value === 'address'"
:data="state.data.value"
@update="actions.updateField"
/>
<ReviewForm v-if="state.currentStepId.value === 'review'" :data="state.data.value" />
</div>
</template>Form Library Integration (VeeValidate)
<script setup lang="ts">
import { useForm } from "vee-validate";
import { useWizard } from "@gooonzick/wizard-vue";
const { handleSubmit, values: formData } = useForm();
const { state, navigation, actions, validation } = useWizard({
definition,
initialData,
});
const handleStepSubmit = async () => {
// Validate current step
await actions.validate();
if (validation.isValid.value) {
await navigation.goNext();
}
};
</script>
<template>
<form @submit="handleSubmit(handleStepSubmit)">
<input
v-if="state.currentStepId.value === 'personal'"
v-model="formData.name"
name="name"
/>
<button type="submit">
{{ navigation.isLastStep.value ? "Complete" : "Next" }}
</button>
</form>
</template>Custom Context with API Client
interface ApiContext extends WizardContext {
api: ApiClient;
onError?: (error: Error) => void;
}
const apiContext: ApiContext = {
api: new ApiClient({
baseURL: "https://api.example.com",
}),
};<script setup lang="ts">
const { state, navigation, actions } = useWizard({
definition: signupDefinition,
initialData: { email: "", plan: "basic" },
context: apiContext,
onError: (error) => {
console.error("Wizard error:", error);
},
});
// The definition can now use apiContext in validators:
// validate: async (data, ctx) => {
// const available = await (ctx as ApiContext).api.checkEmail(data.email);
// return { valid: available };
// }
</script>Persisting Progress
The recommended path is the built-in persistence plugin. See the Plugins guide.
<script setup lang="ts">
import {
createPersistencePlugin,
localStorageAdapter,
} from "@gooonzick/wizard-core";
import { useWizard } from "@gooonzick/wizard-vue";
// Reference-stable: `plugins` is read once, at setup.
const plugins = [
createPersistencePlugin<SignupData>({
adapter: localStorageAdapter<SignupData>("wizard:signup"),
debounceMs: 300,
}),
];
// With the synchronous localStorage adapter the snapshot is applied during
// `setup()`, so the first render already shows the restored step.
const { state, actions, navigation } = useWizard({
definition,
initialData,
plugins,
});
</script>Keep actions.serialize() / actions.restore() for the manual case:
<script setup lang="ts">
import { watch, onMounted } from "vue";
const { state, actions, navigation } = useWizard({ definition, initialData });
// Save the full runtime snapshot on every change.
watch(state, () => {
localStorage.setItem("wizard-state", JSON.stringify(actions.serialize()));
});
// Restore once on mount; clear storage if the saved draft is incompatible.
onMounted(() => {
const saved = localStorage.getItem("wizard-state");
if (!saved) return;
try {
actions.restore(JSON.parse(saved));
} catch {
localStorage.removeItem("wizard-state");
}
});
</script>
<template>
<WizardForm
:state="state"
:actions="actions"
:navigation="navigation"
/>
</template>See examples/vue-examples/src/wizard-example/persistence-plugin-example.vue for the plugin demo and .../state-persistence-example.vue for the manual one.
Handling Errors
<script setup lang="ts">
import { ref } from "vue";
const error = ref<string | null>(null);
const wizard = useWizard({
definition,
initialData: {
/* ... */
},
onError: (err) => {
error.value = err.message;
},
});
</script>
<template>
<div v-if="error" class="error">
<p>{{ error }}</p>
<button @click="error = null">Dismiss</button>
</div>
<WizardForm
v-else
:state="wizard.state"
:actions="wizard.actions"
:navigation="wizard.navigation"
/>
</template>Tracking Progress
The state.progress slice exposes a precomputed WizardProgress snapshot as a ComputedRef, so you don't need to derive totals by hand.
<script setup lang="ts">
const { state } = useWizard({ definition, initialData });
</script>
<template>
<div>
<div
class="progress-bar"
:style="{ width: `${state.progress.value.percentage}%` }"
/>
<p>
Step {{ state.progress.value.currentStepIndex + 1 }} of
{{ state.progress.value.enabledSteps }} ·
{{ state.progress.value.completedSteps }} completed ({{
state.progress.value.percentage
}}%)
</p>
<div v-if="state.currentStepId.value === 'review'" class="review">
<div v-for="stepId in state.progress.value.enabledStepIds" :key="stepId">
<h4>{{ stepId }} — {{ state.stepStatuses.value[stepId] }}</h4>
</div>
</div>
</div>
</template>Loading and Busy States
<script setup lang="ts">
const { navigation, loading } = useWizard({ definition, initialData });
</script>
<template>
<div>
<button
@click="navigation.goPrevious()"
:disabled="!navigation.canGoPrevious.value || loading.isNavigating.value"
>
{{ loading.isNavigating.value ? "Loading..." : "Previous" }}
</button>
<button
@click="navigation.goNext()"
:disabled="loading.isNavigating.value"
>
{{
loading.isValidating.value
? "Validating..."
: navigation.isLastStep.value
? "Finish"
: "Next"
}}
</button>
<p v-if="loading.isSubmitting.value">Submitting...</p>
</div>
</template>Multi-step with Tabs
<script setup lang="ts">
const { state, navigation } = useWizard({ definition, initialData });
const isStepVisited = (stepId: string) => {
return navigation.visitedSteps.value.includes(stepId);
};
</script>
<template>
<div>
<div class="tabs">
<button
v-for="stepId in navigation.availableSteps.value"
:key="stepId"
@click="isStepVisited(stepId) && navigation.goTo(stepId)"
:class="['tab', { active: stepId === state.currentStepId.value }]"
:disabled="!isStepVisited(stepId)"
>
{{ definition.steps[stepId].meta?.title }}
</button>
</div>
<div class="content">
<!-- Render current step -->
</div>
</div>
</template>Organized Composable Return Value
The useWizard() composable returns an organized object with state grouped by concern:
<script setup lang="ts">
const { state, validation, navigation, loading, actions } = useWizard({
definition,
initialData,
});
// State slice - current step and data
const data = state.data.value; // Current form data
const currentStepId = state.currentStepId.value; // Current step
const currentStep = state.currentStep.value; // Current step definition
const isCompleted = state.isCompleted.value; // Is wizard completed?
// Validation slice
const isValid = validation.isValid.value; // Is current step valid?
const validationErrors = validation.validationErrors.value; // Field validation errors
// Navigation slice - state and methods
const canGoNext = navigation.canGoNext.value; // Can move forward?
const canGoPrevious = navigation.canGoPrevious.value; // Can move backward?
const canGoBack = navigation.canGoBack.value; // Can go back via history stack?
const isFirstStep = navigation.isFirstStep.value; // Is on first step?
const isLastStep = navigation.isLastStep.value; // Is on last step?
const visitedSteps = navigation.visitedSteps.value; // Visited step IDs
const availableSteps = navigation.availableSteps.value; // Enabled step IDs
const stepHistory = navigation.stepHistory.value; // Navigation history stack
// Navigation actions
await navigation.goNext(); // Navigate to next
await navigation.goPrevious(); // Navigate to previous (pops history)
await navigation.goBack(n); // Go back n steps (deprecated)
await navigation.goTo(id); // Jump to specific step (validates first)
// Loading slice
const isValidating = loading.isValidating.value; // Validation in progress?
const isSubmitting = loading.isSubmitting.value; // Submission in progress?
const isNavigating = loading.isNavigating.value; // Navigation in progress?
// Actions slice
actions.updateField("name", "John"); // Update form field
actions.updateData((d) => ({ ...d, name: "John" })); // Update with function
actions.setData(newData); // Replace all data
await actions.validate(); // Trigger validation
await actions.canSubmit(); // Check if can submit
await actions.submit(); // Submit current step
actions.reset(); // Reset to initial data
</script>Granular Composables (Optional Provider)
For fine-grained subscriptions that prevent unnecessary re-renders, wrap your component tree with WizardProvider:
<script setup lang="ts">
import {
WizardProvider,
useWizardData,
useWizardNavigation,
useWizardValidation,
useWizardLoading,
useWizardActions,
} from "@gooonzick/wizard-vue";
</script>
<template>
<WizardProvider :definition="myWizard" :initial-data="initialData">
<MyWizardForm />
</WizardProvider>
</template><script setup lang="ts">
// Only subscribes to data changes
const { data, currentStepId } = useWizardData();
// Only subscribes to navigation changes
const { isLastStep, goNext } = useWizardNavigation();
// Only subscribes to validation changes
const { isValid, validationErrors } = useWizardValidation();
// Only subscribes to loading flags
const { isNavigating } = useWizardLoading();
// Actions don't cause re-renders
const { updateField } = useWizardActions();
// Writable field bindings also write through wizard actions
const name = useWizardField<{ name: string }, "name">("name");
</script>
<template>
<div>
<!-- Top-level refs are auto-unwrapped in the template: no .value here -->
<input
v-if="currentStepId === 'personal'"
v-model="name"
/>
<div v-if="!isValid && validationErrors" class="error">
<p>{{ Object.values(validationErrors).join(", ") }}</p>
</div>
<!-- On the last step goNext() completes the wizard; canGoNext is false there. -->
<button @click="goNext()" :disabled="isNavigating">
{{ isLastStep ? "Finish" : "Next" }}
</button>
</div>
</template>Available Granular Composables
| Composable | Returns | Use Case |
|---|---|---|
useWizardData<T>() | Current step, data, isCompleted | Form inputs, step content |
useWizardNavigation() | canGoNext, goNext, goBack, etc. | Navigation buttons |
useWizardValidation() | isValid, validationErrors | Error display |
useWizardLoading() | isValidating, isSubmitting, isNavigating, isLoadingStep | Loading indicators |
useWizardActions<T>() | updateField, validateAll, submit, reset, cancel, serialize, restore | Form handlers |
useWizardField<T>() | Writable computed ref (wizard, field or field-only in Provider) | v-model field binding |
When to Use Granular Composables
Use the provider + granular composables pattern when:
- Your wizard has many components that only need specific slices of state
- You're experiencing performance issues from unnecessary re-renders
- You want to optimize rendering in large forms
Use the standalone useWizard() pattern when:
- You have a simple wizard with few components
- Performance is not a concern
- You prefer a simpler API
Migration Guide
The useWizard() composable now returns an organized object with nested slices and ComputedRef values:
<script setup lang="ts">
// New API structure
const { state, validation, navigation, loading, actions } = useWizard({
definition,
initialData,
});
// Access state (note the .value for ComputedRef)
const data = state.data.value;
const currentStepId = state.currentStepId.value;
// Access navigation
const canGoNext = navigation.canGoNext.value;
await navigation.goNext();
// Access actions
actions.updateField("name", "John");
</script>To adopt granular composables for performance optimization:
<!-- Step 1: Wrap with provider -->
<WizardProvider :definition="definition" :initial-data="initialData">
<MyComponent />
</WizardProvider>
<!-- Step 2: Use granular composables in nested components -->
<script setup lang="ts">
const { data, currentStepId } = useWizardData();
const { canGoNext, goNext } = useWizardNavigation();
const { updateField } = useWizardActions();
</script>Best Practices
1. Separate Concerns
Avoid mirroring the entire wizard data object into a second reactive store with deep watchers. That creates two writers for the same state and can recurse when one watcher feeds the other.
Keep the wizard logic separate from your UI component:
<!-- ❌ Don't: Logic mixed with UI -->
<script setup lang="ts">
const { actions } = useWizard({ definition, initialData });
</script>
<template>
<input @input="actions.updateField('name', $event.target.value)" />
</template><!-- ✅ Do: Use sub-components -->
<script setup lang="ts">
const wizard = useWizard({ definition, initialData });
</script>
<template>
<PersonalStep :state="wizard.state" :actions="wizard.actions" />
</template><script setup lang="ts">
type Props = {
state: UseWizardState<MyData>;
actions: UseWizardActions<MyData>;
};
const props = defineProps<Props>();
</script>
<template>
<input
:value="props.state.data.value.name"
@input="props.actions.updateField('name', $event.target.value)"
/>
</template>2. Handle Async Operations
Always await navigation and validation:
<script setup lang="ts">
// ❌ Don't: Fire and forget
const handleNext = () => {
navigation.goNext(); // Don't await
};
// ✅ Do: Await navigation
const handleNext = async () => {
await actions.validate();
if (validation.isValid.value) {
await navigation.goNext();
}
};
</script>3. Memoize Definition
Create the definition once, outside the component:
<!-- ❌ Don't: Recreate definition on every render -->
<script setup lang="ts">
const definition = createWizard(...).build();
const wizard = useWizard({ definition, ... });
</script>
<!-- ✅ Do: Create once in module scope -->
<script setup lang="ts">
const wizard = useWizard({ definition, ... });
</script>
<script lang="ts">
// Module scope - created once
const definition = createWizard(...).build();
</script>4. Use TypeScript
Leverage the type system:
<script setup lang="ts">
// ✅ Do: Type your data and let TypeScript help
type MyData = {
name: string;
email: string;
};
const wizard = useWizard<MyData>({
definition,
initialData: { name: "", email: "" },
});
// TypeScript knows wizard.data.value.name exists
</script>5. Validate Before Navigation
Check validity before moving to the next step:
<script setup lang="ts">
// ✅ Do: Validate first
const handleNext = async () => {
await actions.validate();
if (validation.isValid.value) {
await navigation.goNext();
}
};
</script>Debugging
Log State Changes
<script setup lang="ts">
const wizard = useWizard({
definition,
initialData,
onStateChange: (state) => {
console.log("State changed:", state);
},
});
</script>Inspect Composable Return Value
<script setup lang="ts">
const wizard = useWizard({ definition, initialData });
console.log(wizard); // See all slices: state, validation, navigation, loading, actions
</script>Use Vue DevTools
Install Vue DevTools browser extension to inspect composable state in real-time.
Enable Debug Mode
<script setup lang="ts">
const wizard = useWizard({
definition,
initialData,
context: { debug: true },
});
</script>Troubleshooting
Wizard won't move to next step
Check:
- Is validation passing? (
validation.isValid.value) - Are all required fields filled?
- Is there an
onErrorhandler showing errors?
Data not updating
Check:
- Are you using
actions.updateField()correctly? - Is the field name correct?
- Use
console.log(state.data.value)to inspect
Context not available
Check:
- Did you pass
contexttouseWizard()? - Are you casting correctly?
ctx as MyContext - Is the context value set?
Performance Optimization
Memoize Sub-Components
<script setup lang="ts">
import { defineComponent, computed } from "vue";
</script>
<script lang="ts">
export default defineComponent({
name: "PersonalStep",
props: ["data", "onUpdate"],
setup(props) {
// Computed properties for optimization
return {};
},
});
</script>Avoid Inline Object Creation
<!-- ❌ Don't: Creates new object every render -->
<script setup lang="ts">
const wizard = useWizard({
definition,
initialData: { name: "", email: "" },
});
</script>
<!-- ✅ Do: Move outside component -->
<script setup lang="ts">
const wizard = useWizard({
definition,
initialData,
});
</script>
<script lang="ts">
// Module scope - created once
const initialData = { name: "", email: "" };
</script>Use Computed Properties
<script setup lang="ts">
const { state } = useWizard({ definition, initialData });
// Computed for derived state
const displayName = computed(() => {
return `${state.data.value.firstName} ${state.data.value.lastName}`;
});
</script>Lazy Load Step Components
<script setup lang="ts">
import { defineAsyncComponent } from "vue";
const PersonalStep = defineAsyncComponent(() => import("./PersonalStep.vue"));
const AddressStep = defineAsyncComponent(() => import("./AddressStep.vue"));
</script>
<template>
<component
:is="state.currentStepId.value === 'personal' ? PersonalStep : AddressStep"
v-bind="$attrs"
/>
</template>