URL sync

A wizard with no extra options reads its starting step from ?step=<key> on the URL and writes the active step back as the user navigates. Reloads land on the same step, deep links render the right step on the first byte under SSR, and the URL stays shareable. To rename the param, scope it across wizards on the same page, or wire the state to non-URL storage, pass restore and persist callbacks.

Category
Restore / persist
Default param
?step=<key>
Opt out
restore: false · persist: false
Custom
restore() => { step? } · persist({ step }) => void

The default behavior

useWizard does URL sync out of the box. Construct a wizard with nothing but steps, and the wizard reads ?step=<key> from the URL at construction and mirrors currentStep back to it on every navigation:

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

const wizard = useWizard({
  steps: ['welcome', shipping, payment, 'final-review'],
})
  • Landing on /checkout?step=payment boots the wizard with currentStep === 'payment'.
  • wizard.next() and wizard.goTo('shipping') update the URL as the active step changes.
  • Reloading the page lands on the same step.
  • The URL is shareable: paste /checkout?step=payment into another tab and that tab opens on the payment step.

The default write uses history.replaceState, so a single back-button press leaves the wizard's host page rather than walking backward through every step the user visited. If you want push semantics (back button retreats one step), see Pushing each navigation below.

SSR hand-off

Under SSR (Nuxt or any framework wired through createAttaform()), the default restore reads the incoming request's ?step from a server-side resolver provided by the integration, so the first byte rendered on the server matches what the URL asked for. The wizard exposes nothing extra here: with default restore and the Nuxt module installed, deep links render the right step on the first paint with no consumer wiring.

A bare-Vue SPA without the Nuxt integration still gets the client-side default: window.location is read on construction once the page hydrates, and the wizard navigates to the matching step before the user sees the first render.

Disabling URL sync

Pass false to either side to opt out of the default. The two switches are independent:

const wizard = useWizard({
  steps: [...],
  restore: false,  // don't read the URL at construction
  persist: false,  // don't write the URL on navigation
})
  • restore: false. The wizard ignores the URL at construction and boots on steps[0]. Useful when the consumer drives initial step from a custom source (a Pinia store, a feature flag, a server-rendered prop) and doesn't want a stray ?step= in the URL to override it.
  • persist: false. Navigation never writes to the URL. The wizard's active step stays in memory only. Useful for embedded wizards inside a modal, a popover, or any context where the URL belongs to the surrounding page.
  • Both false. The wizard is fully URL-blind: starts on steps[0], leaves the URL untouched, never reads from it.

Custom callbacks

The restore and persist options take callbacks for non-URL storage:

type WizardRestoreState = { readonly step?: string }
type WizardRestoreFn = () => WizardRestoreState | undefined
type WizardPersistFn = (state: WizardRestoreState) => void

A localStorage example:

const STORAGE_KEY = 'checkout:active-step'

const wizard = useWizard({
  steps: [shipping, payment, review],
  restore: () => {
    const step = localStorage.getItem(STORAGE_KEY)
    return step === null ? undefined : { step }
  },
  persist: ({ step }) => {
    if (step === undefined) return
    localStorage.setItem(STORAGE_KEY, step)
  },
})

The restore callback is invoked at construction and re-evaluated reactively (its tracked reads decide the dep set); the persist callback fires on every currentStep change, diffed to break the restore-persist loop. The wizard handles the loop break, so a persist write that triggers a restore re-read converges in one round.

Renaming the param

The default ?step=<key> works fine until two wizards land on the same page. Then the second wizard's writes overwrite the first's. Give each wizard its own param via custom callbacks:

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

const checkout = useWizard({
  steps: [shipping, payment, review],
  restore: () => {
    const url = new URL(window.location.href)
    const step = url.searchParams.get('checkout-step')
    return step === null ? undefined : { step }
  },
  persist: ({ step }) => {
    const url = new URL(window.location.href)
    if (step === undefined) {
      url.searchParams.delete('checkout-step')
    } else {
      url.searchParams.set('checkout-step', step)
    }
    history.replaceState(history.state, '', url.toString())
  },
})

const support = useWizard({
  steps: [...],
  restore: () => {
    const url = new URL(window.location.href)
    const step = url.searchParams.get('support-step')
    return step === null ? undefined : { step }
  },
  persist: ({ step }) => {
    const url = new URL(window.location.href)
    if (step === undefined) {
      url.searchParams.delete('support-step')
    } else {
      url.searchParams.set('support-step', step)
    }
    history.replaceState(history.state, '', url.toString())
  },
})

Each wizard owns its own search param; the two never collide.

Pushing each navigation

The default persist uses replaceState so the browser's back button leaves the host page in one press. To get push semantics (back walks the visited steps), swap in a custom persist that calls pushState:

const wizard = useWizard({
  steps: [shipping, payment, review],
  persist: ({ step }) => {
    const url = new URL(window.location.href)
    if (step === undefined) {
      url.searchParams.delete('step')
    } else {
      url.searchParams.set('step', step)
    }
    history.pushState(history.state, '', url.toString())
  },
})

The matching restore (the default one) already listens to popstate and re-reads the URL, so the back button retreats one step at a time once persist pushes.

Where to next

  • useWizard for the construction signature and the wizard handle.
  • Patterns for the per-form persist option that keeps each step's field values across reloads.
  • injectWizard for cross-component access to a wizard with a named key (a separate identifier from ?step=).