Skip to content

Vue Integration Guide

This guide covers how to use the useWizard composable to integrate wizards into your Vue 3 application.

Installation

bash
npm install @gooonzick/wizard-core @gooonzick/wizard-vue

Basic Usage

The useWizard() composable connects a wizard definition to Vue reactive state and provides everything you need to build a wizard UI.

typescript
<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="!navigation.canGoNext.value || loading.isNavigating.value"
    >
      {{ 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().

Schema Validation

You do not need to manually translate schema issues into wizard errors when your form library uses a Standard Schema compatible validator.

typescript
<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

typescript
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;
  onError?: (error: Error) => void;
}

Return Value

The composable returns an organized object with state grouped into five logical slices:

typescript
const { state, validation, navigation, loading, actions } = useWizard({ ... });

State Slice

typescript
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)

Validation Slice

typescript
validation.isValid.value; // Is current step valid? (ComputedRef)
validation.validationErrors.value; // Field-level errors (ComputedRef)
typescript
// 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

typescript
loading.isValidating.value; // Validation in progress? (ComputedRef)
loading.isSubmitting.value; // Submission in progress? (ComputedRef)
loading.isNavigating.value; // Navigation in progress? (ComputedRef)

Actions Slice

typescript
// Update data
actions.updateData((data) => ({ ...data, name: "John" }));
actions.setData(completeData);
actions.updateField("name", "John");

// Validation
await actions.validate(); // Manually validate (result goes to validation slice)
await actions.canSubmit(); // Check if can submit
await actions.submit(); // Submit current step

// Reset
actions.reset(); // Reset to initial data
actions.reset(newData); // Reset with new data

Common Patterns

Conditional Rendering

typescript
<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)

typescript
<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

typescript
interface ApiContext extends WizardContext {
  api: ApiClient;
  onError?: (error: Error) => void;
}

const apiContext: ApiContext = {
  api: new ApiClient({
    baseURL: "https://api.example.com",
  }),
};
typescript
<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

typescript
<script setup lang="ts">
import { ref, watch } from "vue";

const savedData = ref(() => {
  const saved = localStorage.getItem("wizard-data");
  return saved ? JSON.parse(saved) : initialData;
});

const wizard = useWizard({
  definition,
  initialData: savedData.value,
  onStateChange: (state) => {
    // Save after each state change
    localStorage.setItem("wizard-data", JSON.stringify(state.data));
  },
});
</script>

<template>
  <WizardForm
    :state="wizard.state"
    :actions="wizard.actions"
    :navigation="wizard.navigation"
  />
</template>

Handling Errors

typescript
<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

typescript
<script setup lang="ts">
import { computed } from "vue";

const { state, navigation } = useWizard({ definition, initialData });

const progress = computed(
  () =>
    (navigation.visitedSteps.value.length / navigation.availableSteps.value.length) *
    100,
);
</script>

<template>
  <div>
    <div class="progress-bar" :style="{ width: `${progress}%` }" />
    <p>
      Step {{ navigation.visitedSteps.value.length }} of
      {{ navigation.availableSteps.value.length }}
    </p>

    <div v-if="state.currentStepId.value === 'review'" class="review">
      <div v-for="stepId in navigation.visitedSteps.value" :key="stepId">
        <h4>{{ state.currentStep.value.meta?.title }}</h4>
      </div>
    </div>
  </div>
</template>

Loading and Busy States

typescript
<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="!navigation.canGoNext.value || loading.isValidating.value"
    >
      {{ loading.isValidating.value ? "Validating..." : "Next" }}
    </button>

    <p v-if="loading.isSubmitting.value">Submitting...</p>
  </div>
</template>

Multi-step with Tabs

typescript
<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:

typescript
<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:

typescript
<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>
typescript
<script setup lang="ts">
// Only subscribes to data changes
const { data, currentStepId } = useWizardData();

// Only subscribes to navigation changes
const { canGoNext, goNext } = useWizardNavigation();

// Only subscribes to validation changes
const { isValid, validationErrors } = useWizardValidation();

// 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>
    <input
      v-if="currentStepId.value === 'personal'"
      v-model="name"
    />

    <div v-if="!isValid.value && validationErrors.value" class="error">
      <p>{{ Object.values(validationErrors.value).join(", ") }}</p>
    </div>

    <button @click="goNext" :disabled="!canGoNext.value">Next</button>
  </div>
</template>

Available Granular Composables

ComposableReturnsUse Case
useWizardData<T>()Current step, data, isCompletedForm inputs, step content
useWizardNavigation()canGoNext, goNext, goBack, etc.Navigation buttons
useWizardValidation()isValid, validationErrorsError display
useWizardLoading()isValidating, isSubmitting, isNavigatingLoading indicators
useWizardActions<T>()updateField, submit, resetForm handlers
useWizardField<T>()Writable computed ref for one fieldv-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:

typescript
<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:

typescript
<!-- 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:

typescript
<!-- ❌ 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>
typescript
<!--Do: Use sub-components -->
<script setup lang="ts">
const wizard = useWizard({ definition, initialData });
</script>

<template>
  <PersonalStep :state="wizard.state" :actions="wizard.actions" />
</template>
typescript
<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:

typescript
<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:

typescript
<!-- ❌ 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:

typescript
<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:

typescript
<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

typescript
<script setup lang="ts">
const wizard = useWizard({
  definition,
  initialData,
  onStateChange: (state) => {
    console.log("State changed:", state);
  },
});
</script>

Inspect Composable Return Value

typescript
<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

typescript
<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 onError handler 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 context to useWizard()?
  • Are you casting correctly? ctx as MyContext
  • Is the context value set?

Performance Optimization

Memoize Sub-Components

typescript
<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

typescript
<!-- ❌ 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

typescript
<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

typescript
<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>

Released under the MIT License.