Multi-Step Wizards: State That Survives the Back Button
A wizard's state has to outlive its step components, which is why the form model belongs in a subtree-scoped service
What you'll build: a working multi-step wizard architecture
that handles the four things wizards always get wrong — data
preservation across steps, back-button behavior, conditional
branching (step 3 skipped if step 2 answered a certain way), and
resume-where-you-left-off after the user closes the browser and
comes back. Subtree-scoped state service; per-step forms via
FormArray-of-FormGroups; navigation guards for
"you have unsaved changes"; step-completion tracking that
prevents jumping ahead.
Concepts you'll touch: Reactive Forms, Signals, Routing, Components
Time: ~25 minutes to read; ~4 hours to implement a real wizard including persistence and edge-case testing.
The scenario
A B2B SaaS onboarding wizard: five steps to configure a new project. Step 1 collects the project name and team. Step 2 asks about billing (skipped if the team is on the enterprise plan). Step 3 picks integrations. Step 4 imports data. Step 5 confirms and creates the project.
You build it. Users start using it. Support tickets:
- "I filled out steps 1-3, hit back, and step 3 was blank when I returned." Component destruction wiped the form state.
- "I clicked step 5 directly in the sidebar and it let me submit an empty project." No enforcement of step completion order.
- "I closed my browser after step 4 to check something. When I came back, I had to start over." No persistence across sessions.
- "I upgraded to enterprise mid-wizard and step 2 still shows billing questions." Conditional branching didn't re-evaluate.
- "I clicked away accidentally and lost all my progress." No "unsaved changes" guard.
- "The submit button doesn't work if I skipped a step, but doesn't tell me which one is missing." No completion feedback.
Every one of these is a specific design mistake. The recipe walks through the architecture that avoids them — a subtree-scoped state service that owns wizard data, per-step routes with completion guards, and localStorage persistence with a proper resume flow.
The three architectural decisions
Before code, the choices that determine everything else:
1. Where does wizard state live?
- ❌ In each step component's local state → wiped when component unmounts
- ❌ In
providedIn: 'root'service → leaks between different wizard instances, persists after wizard closes - ✅ Subtree-scoped service provided on the wizard shell component → lives for the wizard's lifetime, dies when wizard closes
2. How is navigation between steps handled?
- ❌ Show/hide via conditional templates → the URL doesn't change, no back-button, no deep linking
- ❌ Manual "current step" signal in the shell without routing → same problem, plus refresh loses position
- ✅ Routes per step, with a shared parent route that provides the state service → back button works naturally, URLs are shareable, refresh keeps you on the current step
3. How is data persisted across page reloads?
- ❌ Skip it, hope the user doesn't close the tab → they will
- ❌ Save on every field change → too chatty; localStorage is synchronous
- ✅ Save on step transitions to localStorage → checkpoints; enough to resume
These three decisions are correlated. Subtree-scoped service naturally works with routes-per-step. Both integrate with localStorage checkpointing at step-transition time.
The wizard state service
The heart of the architecture. One service, subtree-scoped, owning all wizard state.
// File: onboarding/wizard-state.service.tsimport { Injectable, computed, effect, inject, signal } from '@angular/core';
export interface WizardStep { id: 'project' | 'billing' | 'integrations' | 'import' | 'confirm'; label: string; completed: boolean; optional?: boolean; visible?: boolean;}
export interface WizardData { project: { name: string; team: string; plan: 'basic' | 'pro' | 'enterprise' } | null; billing: { cardLast4: string; billingEmail: string } | null; integrations: { slack: boolean; github: boolean; jira: boolean } | null; importSource: { type: 'csv' | 'api' | 'none'; url?: string } | null;}
const EMPTY_DATA: WizardData = { project: null, billing: null, integrations: null, importSource: null,};
const STORAGE_KEY = 'onboarding-wizard-state';
@Injectable() // NOT providedIn: 'root' — provided per wizard instanceexport class WizardStateService { private readonly _data = signal<WizardData>(EMPTY_DATA); private readonly _currentStepIndex = signal(0);
readonly data = this._data.asReadonly(); readonly currentStepIndex = this._currentStepIndex.asReadonly();
// Steps definition — computed because visibility depends on data readonly steps = computed<WizardStep[]>(() => { const data = this._data(); const isEnterprise = data.project?.plan === 'enterprise';
return [ { id: 'project', label: 'Project', completed: !!data.project }, { id: 'billing', label: 'Billing', completed: !!data.billing, // Enterprise plans skip billing (invoicing handled elsewhere) visible: !isEnterprise, }, { id: 'integrations', label: 'Integrations', completed: !!data.integrations, optional: true }, { id: 'import', label: 'Import Data', completed: !!data.importSource, optional: true }, { id: 'confirm', label: 'Confirm', completed: false }, ]; });
readonly visibleSteps = computed(() => this.steps().filter(s => s.visible !== false), );
readonly currentStep = computed(() => this.visibleSteps()[this._currentStepIndex()], );
readonly progress = computed(() => { const steps = this.visibleSteps(); const completed = steps.filter(s => s.completed).length; return { current: this._currentStepIndex() + 1, total: steps.length, completed, percentage: Math.round((completed / steps.length) * 100), }; });
readonly canProceed = computed(() => { const step = this.currentStep(); return step?.completed || step?.optional; });
readonly canSubmit = computed(() => { const required = this.visibleSteps().filter(s => !s.optional && s.id !== 'confirm'); return required.every(s => s.completed); });
constructor() { this.restoreFromStorage();
// Persist to localStorage on every state change effect(() => { const snapshot = { data: this._data(), currentStepIndex: this._currentStepIndex(), timestamp: Date.now(), }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } catch { // Storage may be unavailable (private mode, quota); in-memory only is fine } }); }
updateStep<K extends keyof WizardData>(step: K, value: WizardData[K]): void { this._data.update(current => ({ ...current, [step]: value })); }
goToStep(index: number): boolean { const steps = this.visibleSteps(); if (index < 0 || index >= steps.length) return false;
// Prevent jumping past incomplete required steps for (let i = 0; i < index; i++) { const step = steps[i]; if (!step.completed && !step.optional) return false; }
this._currentStepIndex.set(index); return true; }
next(): boolean { return this.goToStep(this._currentStepIndex() + 1); }
previous(): boolean { return this.goToStep(this._currentStepIndex() - 1); }
reset(): void { this._data.set(EMPTY_DATA); this._currentStepIndex.set(0); localStorage.removeItem(STORAGE_KEY); }
hasStoredProgress(): boolean { return localStorage.getItem(STORAGE_KEY) !== null; }
private restoreFromStorage(): void { try { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return;
const snapshot = JSON.parse(stored) as { data: WizardData; currentStepIndex: number; timestamp: number; };
// Expire snapshots older than 7 days const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; if (Date.now() - snapshot.timestamp > MAX_AGE_MS) { localStorage.removeItem(STORAGE_KEY); return; }
this._data.set(snapshot.data); this._currentStepIndex.set(snapshot.currentStepIndex); } catch { localStorage.removeItem(STORAGE_KEY); } }}Seven things doing the work:
@Injectable()withoutprovidedIn— the service is provided per-wizard-instance, not globally. Registered on the wizard shell component'sprovidersarray; created when the wizard mounts, destroyed when it unmounts._datasignal +data.asReadonly()— components read state reactively; only the service can mutate viaupdateStep().stepscomputed with visibility rules — steps that shouldn't apply (e.g., billing for enterprise plans) are filtered out viavisible: false. ThevisibleStepscomputed handles the filtering; the rest of the code sees only the applicable ones.canProceedandcanSubmitcomputed signals — UI reads them directly; buttons enable/disable reactively.canProceedhandles per-step forward navigation;canSubmithandles the final submission.goToStepwith step-order enforcement — jumping to step 4 before step 2 is completed is rejected. Users can't circumvent the flow by clicking directly on a sidebar step number.effect(() => localStorage.setItem(...))— auto-persist on every state change. Not debounced because localStorage writes are fast and steps don't change hundreds of times per second (compare to typing in a search box, which would need debounce).- 7-day expiry on restored state — a user who abandoned the wizard 3 months ago probably doesn't want their old state auto-restored. Bounded staleness prevents confusing "why is this data pre-filled?" moments.
The route structure
Routes per step, with a parent route that provides the wizard shell:
// File: onboarding/onboarding.routes.tsimport { Routes } from '@angular/router';import { WizardStateService } from './wizard-state.service';import { OnboardingShellComponent } from './onboarding-shell.component';import { stepGuard } from './step.guard';
export const onboardingRoutes: Routes = [ { path: '', component: OnboardingShellComponent, providers: [WizardStateService], // ← subtree-scoped state children: [ { path: '', redirectTo: 'project', pathMatch: 'full' }, { path: 'project', loadComponent: () => import('./steps/project-step.component') .then(c => c.ProjectStepComponent), }, { path: 'billing', loadComponent: () => import('./steps/billing-step.component') .then(c => c.BillingStepComponent), canActivate: [stepGuard('billing')], // enforces order }, { path: 'integrations', loadComponent: () => import('./steps/integrations-step.component') .then(c => c.IntegrationsStepComponent), canActivate: [stepGuard('integrations')], }, { path: 'import', loadComponent: () => import('./steps/import-step.component') .then(c => c.ImportStepComponent), canActivate: [stepGuard('import')], }, { path: 'confirm', loadComponent: () => import('./steps/confirm-step.component') .then(c => c.ConfirmStepComponent), canActivate: [stepGuard('confirm')], }, ], },];Three things worth absorbing:
providers: [WizardStateService]on the parent route — the service is instantiated when the parent activates; destroyed when the user navigates away. Child routes inject the same instance.- Lazy-loaded step components — each step's code is a separate chunk. Users who abandon at step 2 never download step 5's code. Composes with the bundle-splitting recipe.
canActivate: [stepGuard(...)]— the guard checks whether previous steps are completed before allowing navigation to a later step.
The step guard
// File: onboarding/step.guard.tsimport { CanActivateFn, Router } from '@angular/router';import { inject } from '@angular/core';import { WizardStateService } from './wizard-state.service';
export function stepGuard(stepId: string): CanActivateFn { return () => { const wizardState = inject(WizardStateService); const router = inject(Router);
const steps = wizardState.visibleSteps(); const targetIndex = steps.findIndex(s => s.id === stepId); if (targetIndex === -1) { router.navigate(['/onboarding']); return false; }
// Check that all previous required steps are completed for (let i = 0; i < targetIndex; i++) { const step = steps[i]; if (!step.completed && !step.optional) { // Redirect to the first incomplete required step router.navigate(['/onboarding', step.id]); return false; } }
return true; };}Functional guard from the routing recipe conventions. Users who deep-link to /onboarding/confirm without completing prior steps get redirected to the first incomplete step.
A step component
Each step has its own form and its own component. The step reads/writes wizard state through the shared service:
// File: onboarding/steps/project-step.component.tsimport { Component, inject } from '@angular/core';import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';import { Router } from '@angular/router';import { WizardStateService } from '../wizard-state.service';
@Component({ selector: 'app-project-step', imports: [ReactiveFormsModule], template: ` <h2>Project Details</h2>
<form [formGroup]="form" (submit)="onNext()"> <label> Project name <input formControlName="name" /> </label>
<label> Team <select formControlName="team"> <option value="">Select a team</option> @for (team of availableTeams(); track team.id) { <option [value]="team.id">{{ team.name }}</option> } </select> </label>
<label> Plan <select formControlName="plan"> <option value="basic">Basic</option> <option value="pro">Professional</option> <option value="enterprise">Enterprise</option> </select> </label>
<div class="actions"> <button type="submit" [disabled]="form.invalid">Continue →</button> </div> </form> `,})export class ProjectStepComponent { private readonly fb = inject(NonNullableFormBuilder); private readonly wizardState = inject(WizardStateService); private readonly router = inject(Router);
readonly availableTeams = signal([ { id: 't1', name: 'Engineering' }, { id: 't2', name: 'Design' }, ]);
readonly form = this.fb.group({ name: this.fb.control('', [Validators.required, Validators.minLength(3)]), team: this.fb.control('', Validators.required), plan: this.fb.control<'basic' | 'pro' | 'enterprise'>('basic', Validators.required), });
constructor() { // Restore existing data if the user is returning to this step const existing = this.wizardState.data().project; if (existing) { this.form.patchValue(existing); } }
onNext(): void { if (this.form.invalid) { this.form.markAllAsTouched(); return; }
const value = this.form.getRawValue(); this.wizardState.updateStep('project', value); this.wizardState.next();
// Navigate to the next step's route const next = this.wizardState.currentStep(); if (next) { this.router.navigate(['/onboarding', next.id]); } }}Three patterns worth absorbing:
- Constructor restoration —
this.form.patchValue(existing)on component mount. Users returning to this step see their previous input, not an empty form. This is the "back button preserves data" requirement in code. markAllAsTouchedon invalid submit — reveals validation errors immediately, since the user just tried to submit. Without this, first-time visitors don't see red error borders because they never touched the fields.- State updated in the shared service, then navigate — the step component doesn't navigate on its own via
next()internally; it saves data first, then routes to the URL corresponding to the next step. Two concerns, two lines.
The wizard shell
Provides the layout — stepper indicator, current step outlet, action buttons:
// File: onboarding/onboarding-shell.component.tsimport { Component, inject } from '@angular/core';import { RouterOutlet, Router } from '@angular/router';import { WizardStateService } from './wizard-state.service';
@Component({ selector: 'app-onboarding-shell', imports: [RouterOutlet], template: ` <div class="wizard"> <aside class="stepper"> <h3>Onboarding</h3> <ol> @for (step of wizard.visibleSteps(); track step.id; let i = $index) { <li [class.active]="wizard.currentStep()?.id === step.id" [class.completed]="step.completed" (click)="jumpTo(i, step)" > <span class="marker">{{ i + 1 }}</span> <span class="label">{{ step.label }}</span> @if (step.optional) { <em>(optional)</em> } </li> } </ol>
<div class="progress"> {{ wizard.progress().current }} of {{ wizard.progress().total }} — {{ wizard.progress().percentage }}% complete </div> </aside>
<main class="content"> <router-outlet /> </main> </div> `,})export class OnboardingShellComponent { protected readonly wizard = inject(WizardStateService); private readonly router = inject(Router);
jumpTo(index: number, step: WizardStep): void { // Only allow jumping if the step is reachable (completed or previous is completed) if (this.wizard.goToStep(index)) { this.router.navigate(['/onboarding', step.id]); } }}Signal reads in the template (wizard.visibleSteps(), wizard.progress(), wizard.currentStep()) automatically update as state changes. No subscriptions, no async pipes.
The resume flow
When a user reopens the wizard after a break, prompt to resume:
// File: onboarding/entry.component.ts@Component({ selector: 'app-onboarding-entry', template: ` @if (hasProgress()) { <div class="resume-card"> <h3>You have unfinished onboarding</h3> <p>Would you like to continue where you left off?</p> <button (click)="resume()">Continue</button> <button (click)="startFresh()">Start over</button> </div> } @else { <button (click)="start()">Start onboarding</button> } `,})export class OnboardingEntryComponent { private readonly wizard = inject(WizardStateService); private readonly router = inject(Router);
readonly hasProgress = signal(this.wizard.hasStoredProgress());
resume(): void { // Restore already happened in the service's constructor; // just navigate to the current step const step = this.wizard.currentStep(); this.router.navigate(['/onboarding', step?.id ?? 'project']); }
startFresh(): void { this.wizard.reset(); this.router.navigate(['/onboarding', 'project']); this.hasProgress.set(false); }
start(): void { this.router.navigate(['/onboarding', 'project']); }}The user sees "You have unfinished onboarding — continue or start over?" — respectful and clear about the state.
Two things worth absorbing:
- The signal
hasProgressis initialized once at construction. Updating it afterstartFreshrequires the explicitset(false). Alternatively, use a computed reading from a service signal for reactive updates. - The service's constructor already restored the state —
resume()doesn't need to do anything special; navigation is enough. The state is ready to render.
Confirm-on-leave with CanDeactivate
If the user has unsaved changes on the current step and tries to navigate away, ask first:
// File: onboarding/leave.guard.tsimport { CanDeactivateFn } from '@angular/router';
export interface HasPendingChanges { hasPendingChanges(): boolean;}
export const leaveGuard: CanDeactivateFn<HasPendingChanges> = (component) => { if (component.hasPendingChanges()) { return confirm('You have unsaved changes on this step. Leave anyway?'); } return true;};Step components implement the interface:
@Component({ /* … */ })export class ProjectStepComponent implements HasPendingChanges { // …existing code…
hasPendingChanges(): boolean { return this.form.dirty && !this.savedSinceDirty; }
private savedSinceDirty = false;
onNext(): void { // …existing submit logic… this.savedSinceDirty = true; }}Register the guard on the step routes:
{ path: 'project', loadComponent: () => import('./steps/project-step.component').then(c => c.ProjectStepComponent), canDeactivate: [leaveGuard],},Note: confirm() (browser dialog) is used here for brevity. A production app should use a custom modal — better UX, cross-browser consistent, matches the app's design system. The recipe stays focused on the guard mechanism.
Conditional / branching steps
The visible flag in the step definition is how conditional steps work. As data changes, the computed steps signal re-runs; steps become visible or hidden.
Concrete example — enterprise plans skip billing:
readonly steps = computed<WizardStep[]>(() => { const data = this._data(); const isEnterprise = data.project?.plan === 'enterprise';
return [ { id: 'project', label: 'Project', completed: !!data.project }, { id: 'billing', label: 'Billing', completed: !!data.billing, visible: !isEnterprise, // ← conditional }, { id: 'integrations', label: 'Integrations', completed: !!data.integrations, optional: true }, { id: 'import', label: 'Import Data', completed: !!data.importSource, optional: true }, { id: 'confirm', label: 'Confirm', completed: false }, ];});If the user starts on the basic plan, fills billing, then goes back to step 1 and upgrades to enterprise, the billing data is preserved in state but the step disappears from the stepper. If they downgrade back to basic, the step re-appears with the previous data intact.
Two subtleties:
- Preserved data: hiding a step doesn't clear its data. If the user re-enters visibility, their previous input is still there. This is usually what you want — users who oscillate between choices don't lose their work.
- The
currentStepIndexmay point to a hidden step: if the user is on step 2 (billing) and upgrades to enterprise, the billing step disappears and the index would point past the current visible step count. Handle this in the effect that watches for step-visibility changes:
constructor() { this.restoreFromStorage();
// If the current step becomes hidden, move forward to the next visible one effect(() => { const visible = this.visibleSteps(); const currentIdx = this._currentStepIndex(); if (currentIdx >= visible.length) { this._currentStepIndex.set(Math.max(0, visible.length - 1)); } }); // ...localStorage persistence effect...}The effect runs whenever step visibility changes. If the current index is out of bounds, snap to the last visible step.
Trade-offs and common pitfalls
Use this wizard architecture when:
- Wizards have 3+ steps (below that, a single form with sections is simpler)
- Users need to return to a step and see their input intact
- Some steps are conditional based on earlier answers
- The wizard is important enough that users abandoning mid-flow is a real cost (onboarding, checkout, complex configuration)
Skip when:
- The "wizard" is really a single form split visually with
@if— no persistence, no back button needed - The user's data doesn't need to persist across sessions (kiosk-style flows)
- The workflow has < 3 steps and step order doesn't matter
Common pitfalls
providedIn: 'root'for wizard state. Leaks between wizard instances (open two wizards in different tabs), persists after the wizard closes (state pollution for the next wizard-open). Always subtree-scoped.- Storing form controls in the wizard service. Tempting because "everything is here," but form controls are UI-tied and don't serialize to localStorage cleanly. Store the plain data; each step's component owns its own form and hydrates from the data on mount.
- Not clearing state on successful submit. The wizard completes; localStorage still has the stale data; the next time the user opens the wizard, they see "You have unfinished onboarding." Call
wizard.reset()after successful submission. - Race between navigation and state update.
wizardState.next(); router.navigate(...)— if next fails, the router still navigates. Do the state update first; check for success; only navigate if it worked. - Skipping the step-order guard. Users deep-linking to
/onboarding/confirmshouldn't work without prior steps. ThestepGuardis essential. - Storing sensitive data in localStorage. Payment info, SSNs, passwords — never. If the wizard collects sensitive fields, either transmit immediately (no local persistence) or accept that the resume-across-sessions feature doesn't apply to those specific fields.
- 7-day expiry too long or too short. A month is often too long (users forget context); a day is often too short (weekend workflow). Tune based on domain; 3-7 days is typical.
effect()writing localStorage without try/catch. Some browsers throw when localStorage is full or in privacy mode. Wrap the write in try/catch — the wizard should still work in-memory even if persistence fails.- Confirm-on-leave that fires for programmatic navigation too.
router.navigatefor going to the next step should not trigger the confirm dialog. SetsavedSinceDirty = true(or clearform.dirty) before navigating. - Not showing progress in the stepper. The sidebar showing "Step 3 of 5" is the load-bearing UX cue that this is a wizard, not a series of unrelated pages. Progress signals are cheap; use them prominently.
- Conditional steps that reset previous data. Hiding a step should not clear its data. Users who oscillate should not lose work.
See also
- Dynamic Forms — form shape variations within a step (conditional sub-forms, FormArray)
- Optimistic Updates — for wizards that save intermediate state to the server (save on step transition, retry on failure)
- Component Communication — the subtree-scoped service pattern (Pattern 4)
- App Initialization — the
CanActivateFnpattern used bystepGuard - Routing —
provideRouter, nested routes, guards - Signals — the state primitive
References
CanDeactivateFn(angular.dev) — the guard for confirm-on-leave- Route providers (angular.dev) — the subtree-provider pattern
StorageAPI (MDN) — localStorage for persistence- Nielsen Norman Group — Wizard Design Patterns — UX principles for multi-step flows
Demo source
Synthesized from common production wizard patterns rather than a single demo file. The subtree-scoped service + routes-per-step + step-order guard architecture is the structure most teams converge on after their first wizard. The seven-day localStorage expiry, the "resume where you left off" flow, and the conditional visibility with preserved-on-hide data are the patterns that separate polished wizards from frustrating ones. All code is original.