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, passrestoreandpersistcallbacks.
- 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'
const wizard = useWizard({
steps: ['welcome', shipping, payment, 'final-review'],
})
- Landing on
/checkout?step=paymentboots the wizard withcurrentStep === 'payment'. wizard.next()andwizard.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=paymentinto another tab and that tab opens on the payment step.
The default write picks its history mode per navigation. A genuine step change calls history.pushState, so the step earns a real entry and the browser's Back and Forward buttons walk the flow one step at a time. Writing the step the URL is already effectively on calls replaceState instead, canonicalizing in place: a bare /checkout resolves to the first step, so stamping ?step=<first> onto it at construction is bookkeeping rather than a navigation. That split is what keeps Back off a dead entry showing the step the user is already looking at, and it keeps the Forward stack alive across a Back round trip.
Under vue-router (Nuxt included), the write goes through the router's own history, so each entry carries the record vue-router keeps on the entries it writes: its address, its position, the entries either side and its scroll. A link away and the browser's Back then return to the step the user left, and scroll restores as it does for any other page. The write is not a navigation: no guard or middleware runs, and useRoute().query.step keeps the value of the last navigation, so read the active step from wizard.currentStep.
Every navigation records an entry, including the wizard's own back(). Clicking the wizard's Back button pushes the earlier step onto the stack rather than popping the later one, so a browser Back press after it returns the user to the step they just left. If that is not the shape you want, own the write with a custom persist, as in Replacing instead of pushing below.
Pages kept by <KeepAlive>
The address belongs to the page on screen, and the default sync follows it there. A wizard reads and writes ?step= from the moment its component mounts, and pauses whenever <KeepAlive> takes its page off screen:
- While its page is hidden, the wizard leaves the address alone. A Back or Forward on another page does not move it, and a step change made meanwhile (a submit that settles after the user moved on, a
goTo()from a watcher) stays in memory until the page comes back. - A Back or Forward moves the wizard only when it lands on the wizard's own path. The browser fires
popstatebefore the router takes a page off screen, so a Back off the page leaves the wizard on its step. - When the page comes back, the wizard catches up. If a Back or Forward moved the page's address to another of its steps, the wizard follows it. If its step changed while hidden, it writes the step with the same push or replace split it uses on screen. If the user came back through a link to the bare page, the wizard keeps its step and writes it onto that entry in place.
- The first write waits for mount too, so a page still resolving under
<Suspense>when the user moves on writes nothing onto the page that replaced it.
None of it needs configuration, and a wizard outside any component (a test, a composable called in an effect scope) syncs as before. Custom restore and persist callbacks run exactly as they always have: a callback owns its source, so it decides when to touch it.
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: [shipping, payment, review],
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 onsteps[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 onsteps[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.
Those tracked reads are the whole story for a custom restore, and the easy thing to get wrong. A callback that reads window.location (or localStorage) directly tracks nothing, so it runs once at construction and never again: the wizard will not follow the browser's Back button, because nothing told it the URL moved. Read a ref you keep in sync instead, and the watcher re-fires the moment that ref changes. The default restore is built exactly this way, which is why Back and Forward work out of the box.
Nothing is lost by restoring only at construction, as long as that is what you meant. The localStorage example above is a fair use of it: there is no back button for a storage key, so a one-shot read on mount is the whole job.
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, and build the pair once so both wizards get the same behavior the default has:
import { onScopeDispose, ref } from 'vue'
import { useWizard } from 'attaform'
function stepParam(param: string) {
const read = (): string | undefined =>
typeof window === 'undefined'
? undefined
: (new URL(window.location.href).searchParams.get(param) ?? undefined)
// The reactive source `restore` tracks. Kept in sync with the URL so
// Back and Forward reach the wizard.
const mirror = ref(read())
if (typeof window !== 'undefined') {
const onPopstate = (): void => {
mirror.value = read()
}
window.addEventListener('popstate', onPopstate)
onScopeDispose(() => window.removeEventListener('popstate', onPopstate))
}
return {
restore: () => (mirror.value === undefined ? undefined : { step: mirror.value }),
persist: ({ step }: { step?: string }): void => {
if (step === undefined || typeof window === 'undefined') return
const url = new URL(window.location.href)
url.searchParams.set(param, step)
// Same split as the default: canonicalize in place, push a move.
if (read() === step) history.replaceState(history.state, '', url.toString())
else history.pushState(history.state, '', url.toString())
mirror.value = step
},
}
}
const checkout = useWizard({ steps: [shipping, payment, review], ...stepParam('checkout-step') })
const support = useWizard({ steps: [topic, details], ...stepParam('support-step') })
Each wizard owns its own search param, so the two never collide, and each keeps its own history entries.
The typeof window guards keep the helper from crashing on the server, but they do not make it server-aware: a custom restore replaces the integration's server-side resolver, so a deep link renders step one on the first byte and corrects on hydration. Under vue-router (Nuxt included), skip the helper and read the route instead. useRoute().query is already reactive and already resolved on the server, so a restore that pulls the step off it keeps both the first byte and the Back button, while router.push in persist owns the history entry. Narrow the value on the way out: a query param can repeat, so route.query[name] is not a string until you say it is.
Like any custom pair, the helper runs whether or not its page is on screen. On a page kept by <KeepAlive>, have both callbacks check that the page is shown and the address is on its path, which is what the default does for you (see Pages kept by <KeepAlive>).
Replacing instead of pushing
Walking the steps with Back is the right default for a page-level flow, and the wrong one for a wizard inside a modal, where the user expects Back to close the surrounding page in a single press. Swap in a custom persist that always replaces, so the URL still reflects the step but the history stack never grows:
const wizard = useWizard({
steps: [shipping, payment, review],
persist: ({ step }) => {
if (step === undefined) return
const url = new URL(window.location.href)
url.searchParams.set('step', step)
history.replaceState(history.state, '', url.toString())
},
})
The default restore still reads the URL at construction and still listens for popstate, so a reload or a shared link lands on the right step. Only the stack behavior changes: there is one entry for the whole wizard, and Back leaves the page.
Under vue-router (Nuxt included), write through the router instead: router.replace({ query: { ...router.currentRoute.value.query, step } }), with const router = useRouter() taken in setup. vue-router writes its own address back onto an entry each time it leaves it, so a replaceState made behind its back is undone by the next link away. router.replace is a navigation, so route guards and middleware run on each step.
Where to next
useWizardfor the construction signature and the wizard handle.- Resumable wizards for the full session round-trip: the active step plus each form's values and gate clearance.
- Patterns for the shapes this sits inside: per-step keys, per-step undo, and restoring the active step on a reload.
injectWizardfor cross-component access to a wizard with a namedkey(a separate identifier from?step=).