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="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:

ts
// 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.

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

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)
state.stepStatuses.value; // Record<StepId, StepStatus> — status of every step
state.progress.value; // WizardProgress — computed totals, index, percentage

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)
loading.isLoadingStep.value; // A lazy step's implementation is loading? (ComputedRef, see Lazy Steps)

Actions Slice ​

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

vue
<script setup lang="ts">
const { actions } = useWizard({
  definition,
  initialData,
  onDataChange: (prev, next, changedFields) => {
    if (changedFields.includes("plan")) {
      actions.updateField("price", PRICES[next.plan]);
    }
  },
});
</script>
typescript
// 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.

vue
<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.

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

ts
useWizard({
  definition,
  initialData,
  onCancel: (data) =>
    analytics.track("wizard_cancelled", { email: data.email }),
  onReset: () => analytics.track("wizard_reset"),
});

Declarative server cleanup via the wizard definition:

ts
createWizard<SignupData>("signup")
  // …steps…
  .onCancel(async (data) => {
    await api.deleteDraft(data.draftId);
  })
  .build();

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 ​

The recommended path is the built-in persistence plugin. See the Plugins guide.

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

vue
<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 ​

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 ​

The state.progress slice exposes a precomputed WizardProgress snapshot as a ComputedRef, so you don't need to derive totals by hand.

vue
<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 ​

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="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 ​

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 { 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 ​

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, isNavigating, isLoadingStepLoading indicators
useWizardActions<T>()updateField, validateAll, submit, reset, cancel, serialize, restoreForm 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:

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.