Statuses

wizard.statuses is a per-step FormStatus rollup that mirrors each form's meta. Read it drillably for templates, call it for a snapshot, or seed it up-front with defaultStatuses for resume flows that need to render filled rails before per-form data lands.

Category
Reactive surface
Shape
{ valid, dirty, submitted, errorCount }
Read patterns
drillable · callable · called with a key
Seeding
defaultStatuses (object, sync factory, async factory)

The FormStatus shape

type FormStatus = {
  readonly valid: boolean
  readonly dirty: boolean
  readonly submitted: boolean
  readonly errorCount: number
}

Each field tracks the per-step form's meta. A step's status flips when its meta does. The four scalars are deliberately small. They're what step indicators, navigation gates, and submit summaries reach for.

Reading patterns

wizard.statuses is both a drillable record and a callable accessor. Same data, three call shapes:

import { useForm, useWizard } from 'attaform/zod'
import { z } from 'zod'

const accountSchema = z.object({ email: z.email() })
const profileSchema = z.object({ name: z.string().min(1) })

const account = useForm({ schema: accountSchema, key: 'signup-account' })
const profile = useForm({ schema: profileSchema, key: 'signup-profile' })

const wizard = useWizard({ steps: [account, profile] })

wizard.statuses // drillable record
wizard.statuses() // { 'signup-account': FormStatus, 'signup-profile': FormStatus }
wizard.statuses('signup-account') // FormStatus for one step
wizard.statuses['signup-account'] // FormStatus for one step (drillable)
wizard.statuses['signup-account'].valid // boolean

The drillable form is the template-friendly read; the callable form is convenient in script for one-off reads or destructured snapshots.

Status rails

The classic use case is a step indicator: one dot per form, painted with its current state. wizard.steps walks the compiled positions; wizard.statuses[step.key] reads each one's status:

<script setup lang="ts">
  import { useForm, useWizard } from 'attaform/zod'

  const wizard = useWizard({ steps: [account, profile, review] })
</script>

<template>
  <ol class="wizard-rail">
    <li v-for="step in wizard.steps" :key="step.key">
      <span
        class="dot"
        :class="{
          done: wizard.statuses[step.key].valid,
          dirty: wizard.statuses[step.key].dirty,
          current: wizard.currentStep === step.key,
        }"
      />
      {{ step.key }}
    </li>
  </ol>
</template>

Affordance steps (bare-string slots) carry an always-valid status, so the rail can paint every position without special-casing them. See Step slots for the affordance-slot story.

Seeding with defaultStatuses

Resume flows (e-commerce checkouts reopened mid-purchase, partially-completed onboarding, draft restore) often need to render filled rails before any per-form data has loaded. The defaultStatuses option seeds wizard.statuses up-front. Three shapes mirror the defaultValues trichotomy.

A plain object for compile-time-known seeds:

const wizard = useWizard({
  steps: [account, profile, review],
  defaultStatuses: {
    'signup-account': { valid: true, dirty: false, submitted: true, errorCount: 0 },
    'signup-profile': { valid: false, dirty: true, submitted: false, errorCount: 1 },
    'signup-review': { valid: false, dirty: false, submitted: false, errorCount: 0 },
  },
})

A sync factory for seeds derived from synchronous state (a draft snapshot in a Pinia store, a URL parameter, a cookie):

const wizard = useWizard({
  steps: [account, profile, review],
  defaultStatuses: () => buildStatusesFromDraft(draftStore.snapshot),
})

An async factory for seeds that need a server round-trip (saved flow state, a server-rendered status payload):

const wizard = useWizard({
  steps: [account, profile, review],
  defaultStatuses: async () => fetchSavedFlowStatuses(userId),
})

The seed fills in until the real form data lands. Resolution priority per step:

  1. The step's form has defaultsResolved === true (its async / sync defaults have settled). Status derives from form.meta.
  2. The step is an affordance (noop form). The built-in always-valid status renders.
  3. The step has a seed entry from defaultStatuses. The seed value renders.
  4. Otherwise, a pending status renders (valid: false, dirty: false, submitted: false, errorCount: 0).

Unknown keys in the seed object dev-warn at construction; the wizard ignores them. Known keys still apply, so a partial seed is fine.

Reacting to status changes

wizard.statuses is reactive, so Vue's watch is the right tool for one-off side effects (analytics, autosave, a celebration toast when the last step flips valid). The status proxy plugs into Vue's reactivity the same way form.meta does:

import { watch } from 'vue'
import { useForm, useWizard } from 'attaform/zod'

const wizard = useWizard({ steps: [account, profile, review] })

watch(
  () => wizard.statuses['signup-profile'].valid,
  (isValid) => {
    if (isValid) analytics.track('profile_complete', { user: userId })
  }
)

For a whole-wizard sweep, watch the callable form and diff against the previous snapshot:

watch(
  () => wizard.statuses(),
  (next, prev) => {
    for (const [key, status] of Object.entries(next)) {
      if (status.valid && !prev[key]?.valid) {
        analytics.track('step_valid', { key })
      }
    }
  },
  { deep: true }
)

Where to next

  • useWizard for the construction signature and the wizard's full reactive surface.
  • Aggregates for wizard.allValues and wizard.allErrors.
  • handleSubmit for the submission pipeline that flips per-form submitted.