Step slots
Slots are the entries that fill
useWizard({ steps }). A flow's shape lives entirely in the array: forms for collection screens, bare strings for affordance screens, functions for runtime branching, andlazy()for memoized resolution that re-fires only on its own tracked reactive reads. Each slot compiles into a uniform{ key, form }step, so the navigation, status, and submission machinery never has to special-case a kind.
- Category
- Concept
- Slot kinds
Form · string · function · lazy()- Compiled shape
{ key, form }- Drop behavior
function slot returns undefined => drop
The previous page (useWizard) introduced slots at a glance. This page is the deep dive: what each slot kind brings, the ctx shape that function and lazy() slots receive, and the rules around drop, dedup, and re-evaluation.
The demo below stitches all four kinds into one flow: a 'welcome' string, a single attendee form, a function slot that branches by role (and returns a bare string for the no-extras path), another function slot that drops when traveling solo, a lazy() resolver for the regional pricing form, and a 'review' string at the end.
Welcome aboard
This wizard exercises all four slot kinds: an affordance string here, a form coming up next, a function slot that branches by role, and a lazy() resolver that memoizes by its tracked reactive reads.
No data is collected on this step.
The four kinds at a glance
import { useForm, useWizard, lazy } from 'attaform/zod'
const shipping = useForm({ schema: shippingSchema, key: 'shipping' })
const business = useForm({ schema: businessSchema, key: 'business' })
const consumer = useForm({ schema: consumerSchema, key: 'consumer' })
const wizard = useWizard({
steps: [
'welcome', // affordance slot (string)
shipping, // form slot
(ctx) => (ctx.forms.shipping.values.kind === 'business' ? business : consumer), // function slot
lazy((ctx) => buildSummaryFormFor(ctx)), // memoized lazy slot
'congrats', // affordance slot
],
})
Five positions, all uniform downstream. wizard.currentStep, wizard.statuses[key], and wizard.handleSubmit operate the same whether the active step came from a form ref or a string. The slot kind shapes how the position resolves, not what it produces.
Form slots
A form built with useForm slotted directly into the array. The wizard surfaces it as-is: the form's key, schema, fields, values, and submission pipeline are reachable through wizard.forms[form.key], wizard.statuses[form.key], wizard.allValues[form.key], and friends.
const shipping = useForm({ schema: shippingSchema, key: 'shipping' })
const payment = useForm({ schema: paymentSchema, key: 'payment' })
const wizard = useWizard({ steps: [shipping, payment] })
The same form ref can be shared with anything else in the app (props, injectForm, a different wizard). The wizard ref-counts each form for its lifetime; tearing down the wizard releases the consumer count without disposing forms that other components still hold.
Affordance slots (string)
A bare string. The wizard generates a noop form under that key, backed by an empty AbstractSchema that always validates as {}. Affordance positions never collect data: welcome cards, terms-and-conditions panels, review surfaces, and confirmation cards each occupy one string in the array.
const wizard = useWizard({
steps: ['welcome', shipping, payment, 'order-review', 'confirmation'],
})
Every downstream surface treats the string slot identically to a form slot:
wizard.steps[i]reads as{ key: 'welcome', form: <noopForm> }.wizard.statuses['welcome']reads as{ valid: true, errorCount: 0, ... }.wizard.allValues['welcome']is the empty record{}.wizard.handleSubmiton an affordance step validates as{}and advances.
The noop form is real: it carries a key, sits in the per-app registry, and participates in injectForm('welcome') lookups the same way any other form does. Affordance steps are first-class building blocks, not edge cases.
When two string slots collide on a key
useWizard({ steps: ['welcome', shipping, 'welcome'] }) // duplicate 'welcome'
First occurrence wins. The second dev-warns and drops. The wizard still navigates without crashing.
Function slots
A function that picks one of the three slot kinds at runtime: (ctx) => Form | string | undefined. The wizard re-invokes function slots reactively whenever the reads inside them change, so branching logic stays in sync with live form values.
const account = useForm({ schema: accountSchema, key: 'account' })
const business = useForm({ schema: businessSchema, key: 'business' })
const consumer = useForm({ schema: consumerSchema, key: 'consumer' })
const wizard = useWizard({
steps: [
account,
(ctx) => (ctx.forms.account.values.kind === 'business' ? business : consumer),
'confirmation',
],
})
When the user picks 'business' on the account step, the branching slot resolves to business. Switching back to 'consumer' swaps the resolved form. wizard.steps, wizard.forms, and the progress rail follow along.
Return values
| Return | Result |
|---|---|
An AnyForm ref | The slot compiles to { key: form.key, form }. |
A string key | The slot resolves to a noop affordance step under that key. New keys are built on the fly; the same key returned twice reuses the same noop. No pre-declaration needed elsewhere in steps. |
undefined | The slot is dropped from the compiled list. Useful for "this branch isn't relevant right now"; the step rail shortens accordingly. |
Reactive re-evaluation
Function slots re-evaluate whenever the wizard's compiled list re-evaluates, which includes the case where another slot's deps changed. Keep slot bodies cheap: a branch on ctx.forms.<key>.values.<path> is fine; a fetch(...) is not (slot evaluation is synchronous, and re-evaluating an expensive lookup on every keystroke is wasted work). Reach for lazy() when the resolver is heavy and should only re-fire on its own tracked reads.
Dropping a slot keeps navigation honest
const wizard = useWizard({
steps: [
account,
(ctx) => (ctx.forms.account.values.needsId ? idVerification : undefined),
confirm,
],
})
When the user toggles needsId off, the middle slot drops. wizard.count falls from 3 to 2; wizard.activeIndex and wizard.currentStep re-anchor; navigation buttons reflect the new positions.
Lazy slots (lazy())
What problem lazy() solves
Plain function slots re-evaluate whenever the wizard's compiled list re-evaluates, which happens any time any slot's reactive reads move. That's perfect for the common case: cheap branches on live values reading ctx.forms.<key>.values.<path> and returning a form or a string. The wizard re-compiles, the function slot runs again, no harm done.
The pattern stops scaling when a resolver is expensive enough that running it on every wizard mutation produces visible thrash. A factory that builds a region-specific schema from already-loaded config. A heavy validator derived from runtime data. A tenant-specific form layout assembled from server-resolved defaults. Plain function slots re-fire that resolver every time the user toggles any field anywhere in the wizard, because every field edit re-triggers the compiled list.
Resolver bodies are synchronous, so async work belongs upstream: load the source data with Nuxt's useAsyncData / useFetch (which already transfers the result from server to client), then read the resolved ref inside the lazy resolver to derive a form.
lazy() is the opt-in cache for those resolvers. Each lazy slot gets its own memoized computed: the resolver fires once on the first compile pass, and the result holds until one of the resolver's own tracked reactive reads changes. Reads elsewhere in the wizard (other slots' deps, navigation churn, sibling form mutations) leave the cache intact. It's the same opt-in memoization Vue's computed gives you anywhere else, applied at the slot level.
// Plain function slot: re-fires every time the compiled list re-evaluates,
// which includes unrelated form edits elsewhere in the wizard.
;(ctx) => buildPricingFor(ctx.forms.account.values.region)
// Lazy slot: re-fires only when `region` actually changes.
lazy((ctx) => buildPricingFor(ctx.forms.account.values.region))
How it works
import { useForm, useWizard, lazy } from 'attaform/zod'
const wizard = useWizard({
steps: [
account,
lazy((ctx) => buildShippingFormForRegion(ctx.forms.account.values.region)),
confirm,
],
})
The buildShippingFormForRegion(...) call fires on the first compile pass. When the user later edits region, the resolver re-fires because region is one of its tracked reactive reads, and the rail swaps to the freshly-built form. Changes to unrelated wizard state (a different form's field, another slot's branch) leave the cache untouched. wizard.reset() bumps an internal epoch so every lazy resolver re-fires on the next compile pass, regardless of whether any tracked read moved; that's what makes a reset a true reboot.
// One-shot resolution: read the snapshot outside the resolver so the
// closure has no reactive deps. Then `lazy()` only re-fires on reset.
const initialRegion = account.values.region
lazy(() => buildShippingFormForRegion(initialRegion))
Resolution semantics
| Return | Behavior |
|---|---|
An AnyForm ref | The form caches at that position. Subsequent reads reuse it until a tracked dep changes or reset() invalidates the cache. |
A string key | Resolves to a noop affordance step under that key, building one on the fly if needed. Result caches under the same dep-tracking rules as a form return. |
undefined | The slot drops from the compiled list. The drop caches; a tracked dep change or reset() re-fires the resolver. |
Use lazy() when the resolution is expensive enough that thrash matters. For everyday branching on live values, plain function slots are simpler. They re-evaluate freely with the compiled list, and the wizard pays no cache-bookkeeping cost.
The ctx surface
Function slots and lazy() resolvers each receive a single ctx argument:
type WizardCtx = {
readonly forms: Readonly<Record<FormKey, WizardCtxForm>>
readonly currentKey: FormKey | undefined
}
type WizardCtxForm = AnyForm & {
readonly values: Readonly<Record<string, unknown>>
}
ctx.forms.<key>is the projection over every form reachable through a top-level slot. Reads are loose-typed (unknown), since the wizard does not generically thread each form's schema through this surface. For typed access, close over the original form ref:const account = useForm({ schema: accountSchema, key: 'account' }) const wizard = useWizard({ steps: [ account, (ctx) => (account.values.kind === 'business' ? business : consumer), // typed! // not: ctx.forms.account.values.kind (loose-typed) ], })
Both forms work at runtime. The closed-over ref keeps the schema type intact through the predicate, which IDEs and review-flag tooling appreciate.ctx.currentKeyis the key of the step currently active. Reads asundefinedon the first compile pass before activation lands, so guard with!== undefinedwhen the slot's decision depends on position.
The compiled step shape
Each surviving slot compiles to a CompiledStep:
type CompiledStep = {
readonly key: FormKey
readonly form: AnyForm
}
wizard.steps[i] reads as { key, form }. The list is ordered, dedupes by form key (first occurrence wins), and drops any slot whose resolver returned undefined. The compiled list is what every downstream surface walks: navigation, the progress rail, statuses, aggregates.
for (const step of wizard.steps) {
console.log(step.key, step.form.meta.valid)
}
Drop and dedup semantics
A few rules govern how the source slot list compiles down to the final step list:
- Duplicate keys. Two slots producing the same step key (string slot vs form ref, or two functions returning the same form): first occurrence wins. Later duplicates dev-warn and drop. Keeps
wizard.stepslinearly addressable. undefinedfrom a function slot. The slot drops; subsequent reads ofwizard.stepsreflect the shortened list. Re-running the slot (a reactive read changed) can reintroduce the position.undefinedfrom alazy()slot. The slot drops. The drop caches; a tracked-dep change orreset()re-fires the resolver, and if it returnsundefinedagain, the slot drops again.- Function or
lazy()slot returns a new string key. The wizard builds a noop affordance step on the fly under that key and threads it into the compiled list, the statuses surface, and the rail. The same key returned by a later slot reuses the same noop (first-build wins). No pre-declaration anywhere instepsis required. - Empty compiled list. If every slot drops at runtime,
wizard.currentStepreads asundefined, navigation refuses with a dev-warn, and the surrounding app keeps rendering. See Degenerate inputs.
Where to next
useWizardfor the construction signature and full reactive surface.- Statuses for the per-step rollup that drives a rail.
- Aggregates for
wizard.allValues,wizard.allErrors, andwizard.forms. - handleSubmit for the universal submission pipeline that handles every slot kind uniformly.
- Patterns for branching, review surfaces, and lazy heavy slots in real flows.