injectWizard

Reach a registered wizard from any descendant component. Ambient resolution for the parent's own wizard, keyed resolution for distant ones, and a single null on miss instead of a thrown error so floating panels, sticky nav rails, and sidebar widgets stay robust to mount-order quirks.

Category
Composable
Signature
injectWizard(input?: string | { key? }) => UseWizardReturnType | null
Ambient mode
useWizard({ steps })
Explicit mode
useWizard({ steps, key })

useWizard creates and provides the wizard handle; injectWizard looks it up. The two compose the way useForm and injectForm do, scaled up to the wizard handle so a progress rail, a floating finish button, or a deep-tree review summary can reach the wizard without prop-threading.

The common case, ambient resolution

The parent owns the wizard (no key):

<!-- CheckoutWizard.vue -->
<script setup lang="ts">
  import { useForm, useWizard } from 'attaform/zod'
  import { z } from 'zod'

  const shippingSchema = z.object({ address: z.string(), city: z.string() })
  const paymentSchema = z.object({ cardNumber: z.string(), cvv: z.string() })

  const shipping = useForm({ schema: shippingSchema, key: 'shipping' })
  const payment = useForm({ schema: paymentSchema, key: 'payment' })

  const wizard = useWizard({
    steps: ['welcome', shipping, payment, 'final-review'],
  })
</script>

<template>
  <ProgressRail />
  <StepBody />
  <NavButtons />
</template>

Any descendant grabs the same wizard:

<!-- ProgressRail.vue -->
<script setup lang="ts">
  import { injectWizard } from 'attaform/zod'

  const wizard = injectWizard()
</script>

<template>
  <ol v-if="wizard">
    <li
      v-for="(step, i) in wizard.steps"
      :key="step.key"
      :class="{
        done: wizard.statuses[step.key]?.valid,
        current: wizard.currentStep === step.key,
      }"
    >
      <button type="button" @click="wizard.goTo(step.key)">Step {{ i + 1 }}</button>
    </li>
  </ol>
</template>

The rail reads wizard.currentStep, wizard.statuses, and wizard.steps exactly the way the parent does. Same reactive surface, same identity. Updates in the parent propagate to the child without a roundtrip.

Reaching a wizard that isn't an ancestor

Sticky finish buttons, sidebar status widgets, or any component in a different branch of the tree look up the wizard by key:

<!-- CheckoutWizard.vue -->
<script setup lang="ts">
  const wizard = useWizard({
    steps: ['welcome', shipping, payment, 'final-review'],
    key: 'checkout-wizard',
  })
</script>
<!-- FloatingFinishButton.vue (anywhere in the app) -->
<script setup lang="ts">
  import { injectWizard } from 'attaform/zod'

  const wizard = injectWizard('checkout-wizard')

  const finish = wizard?.handleSubmit(async (ctx) => {
    if (!ctx.isFinal) return
    await api.checkout(ctx.values)
  })
</script>

<template>
  <button v-if="wizard" :disabled="!wizard.complete" @click="finish">Finish</button>
</template>

Pass the same key the parent passed to useWizard({ key: 'checkout-wizard' }). The handle returned is identity-equal to the parent's, so wizard.handleSubmit wired from a floating button runs the same submission pipeline the parent's Finish button would.

injectWizard accepts an object form too: injectWizard({ key: 'checkout-wizard' }). The positional and object forms are equivalent; pick whichever spreads better into the surrounding setup.

Do I need to pass a key to useWizard?

The two resolution modes are cleanly split:

  • Anonymous (no key) reaches ambient. useWizard({ steps }) fills the parent's ambient slot. Any descendant's injectWizard() (no key) resolves to it; closest ancestor wins when nested.
  • Keyed (key: 'x') reaches explicit access only. useWizard({ steps, key: 'x' }) registers the wizard under 'x' but does NOT fill the ambient slot. Descendants reach it via injectWizard('x'), not via the no-key form.

Skip key for single-component wizards (an in-page checkout, a modal flow). Supply one when you want cross-tree lookup, a stable identifier for DevTools, or a sticky finish button rendered far from the step container.

Gotcha: multiple anonymous useWizard in one component

Vue's provide / inject is last-write-wins per component. If a parent calls useWizard twice without keys, the second overwrites the first in the ambient slot, and descendants using injectWizard() only see the second.

// Parent component
const checkout = useWizard({ steps: [shipping, payment] }) // ambient → checkout
const cancel = useWizard({ steps: [reasons, confirm] }) // ambient → cancel (overwrites checkout)
// Descendants' injectWizard() reads cancel. checkout is unreachable via ambient.

Attaform emits a dev-mode console.warn lazily, when (and only when) a descendant actually consumes the ambient slot via injectWizard() with no key. The warning lists each anonymous useWizard() call by source frame so you can navigate to the offending sites.

Fix: give each wizard a key (which removes them from the ambient slot entirely) and look them up explicitly:

useWizard({ steps: [shipping, payment], key: 'checkout' })
useWizard({ steps: [reasons, confirm], key: 'cancel' })
// Descendants:
const checkout = injectWizard('checkout')
const cancel = injectWizard('cancel')

Mixing modes is fine. Keyed wizards don't interfere with an ambient sibling. A parent with three keyed wizards plus one anonymous wizard produces no warning; the descendant's injectWizard() unambiguously resolves to the (only) anonymous one.

When resolution fails

injectWizard returns null rather than throwing, so descendants are robust to mount-order quirks (a sidebar widget that renders before the wizard's parent setup runs, a conditional wizard ancestor, dynamic imports). Two cases produce null:

  • No ambient wizard. injectWizard() called from a tree with no ancestor useWizard and no key. Returns null silently. Ambient lookup is opportunistic, so a floating widget reading the ambient slot stays quiet in trees that don't have a wizard rather than spamming consumers' consoles.
  • Key not registered. injectWizard('checkout-wizard') called when nothing is registered under that key. Dev mode logs the unresolved key alongside any keys that ARE registered, so a typo surfaces at a glance.

Guard the return so the consumer disappears cleanly when the wizard isn't mounted:

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

  const wizard = injectWizard('checkout-wizard')
</script>

<template>
  <aside v-if="wizard" class="wizard-status">
    Step {{ wizard.activeIndex + 1 }} of {{ wizard.count }}
  </aside>
</template>

Lifetime

Both resolution modes ref-count the wizard handle in the registry. In practice:

  • The wizard survives until every component that reached it unmounts.
  • Cleanup is automatic; no explicit dispose call from the consumer.
  • A wizard accessed only by injectWizard(key) stays alive as long as at least one consumer is mounted, even if the parent useWizard owner unmounted first.

Hot-module reload reuses the existing handle when the parent SFC re-mounts (deferred-eviction-cancel within the same microtask). Child injectWizard consumers see the same wizard reactive surface they had before, not a freshly created one, so a rail's pre-filled state survives every save.

Duplicate keys

Two calls to useWizard({ steps, key: 'checkout-wizard' }) in the same app: the first wizard stays in the registry under that key, the second call dev-warns and the registry entry is left untouched. Any injectWizard('checkout-wizard') resolves to the original. The dev-warn names the colliding key so the accidental duplicate setup surfaces at a glance.

SSR isolation

The wizard registry lives on the per-request AttaformRegistry instance created by createAttaform(). A wizard registered in one server request does not leak into a sibling request rendering at the same time. The same isolation applies to forms registered through injectForm.

Where to next

  • useWizard for the construction signature and the wizard's full reactive surface.
  • Statuses for the per-step rollup that drives a progress rail's classes.
  • Step slots for the slot kinds that fill the steps list.
  • injectForm for single-form sharing across a tree.