rom component-driven flows to event-driven state orchestration. The architecture separates step configuration, state evaluation, and UI rendering. This enables dynamic progression, session recovery, and analytics-driven iteration without redeploying frontend code.
Step 1: Define a Config-Driven Step Schema
Steps are declarative objects that describe prerequisites, visibility rules, and completion triggers. This removes hardcoded logic from components.
export interface OnboardingStep {
id: string;
title: string;
category: 'auth' | 'configuration' | 'integration' | 'validation';
prerequisites: string[]; // step IDs that must complete first
readinessConditions: (state: OnboardingState) => boolean;
completionEvent: string; // analytics/system event that marks step done
uiComponent: string; // lazy-loaded component key
fallbackBehavior: 'skip' | 'block' | 'defer';
}
export interface OnboardingState {
userId: string;
currentStep: string | null;
completedSteps: Set<string>;
pendingEvents: Map<string, number>; // event ID -> timestamp
role: string;
environment: 'web' | 'mobile' | 'desktop';
lastSync: number;
}
Step 2: Implement a Finite State Machine for Progression
A state machine evaluates readiness, enforces dependency graphs, and handles session recovery. It replaces conditional rendering with deterministic transitions.
export class OnboardingOrchestrator {
private state: OnboardingState;
private steps: Map<string, OnboardingStep>;
private eventBus: EventTarget;
constructor(steps: OnboardingStep[], initialState: OnboardingState) {
this.steps = new Map(steps.map(s => [s.id, s]));
this.state = { ...initialState, completedSteps: new Set(initialState.completedSteps) };
this.eventBus = new EventTarget();
this.recoverFromStorage();
}
private recoverFromStorage(): void {
const saved = localStorage.getItem('onboarding_state');
if (saved) {
const parsed = JSON.parse(saved);
this.state.completedSteps = new Set(parsed.completedSteps);
this.state.currentStep = parsed.currentStep;
}
}
private persistState(): void {
localStorage.setItem('onboarding_state', JSON.stringify({
...this.state,
completedSteps: Array.from(this.state.completedSteps)
}));
}
public evaluateNextStep(): string | null {
const readySteps = Array.from(this.steps.values())
.filter(step => {
const prerequisitesMet = step.prerequisites.every(id => this.state.completedSteps.has(id));
const readiness = step.readinessConditions(this.state);
const notCompleted = !this.state.completedSteps.has(step.id);
return prerequisitesMet && readiness && notCompleted;
})
.sort((a, b) => a.category.localeCompare(b.category)); // deterministic ordering
this.state.currentStep = readySteps[0]?.id ?? null;
this.persistState();
return this.state.currentStep;
}
public markCompleted(stepId: string): void {
this.state.completedSteps.add(stepId);
this.persistState();
this.eventBus.dispatchEvent(new CustomEvent('step_completed', { detail: { stepId } }));
this.evaluateNextStep();
}
public on(event: string, handler: (e: Event) => void): void {
this.eventBus.addEventListener(event, handler);
}
}
Step 3: Bind UI to State via Progressive Disclosure
The UI layer subscribes to state changes and renders only the active step. It never forces progression; it reacts to completion events.
import { useEffect, useState } from 'react';
export function OnboardingRenderer({ orchestrator }: { orchestrator: OnboardingOrchestrator }) {
const [activeStep, setActiveStep] = useState<string | null>(orchestrator.evaluateNextStep());
useEffect(() => {
const handler = () => setActiveStep(orchestrator.evaluateNextStep());
orchestrator.on('step_completed', handler);
return () => orchestrator.eventBus.removeEventListener('step_completed', handler);
}, [orchestrator]);
if (!activeStep) return <div className="onboarding-complete">Onboarding finished.</div>;
const StepComponent = lazyLoadedComponents[activeStep];
return <StepComponent onComplete={() => orchestrator.markCompleted(activeStep)} />;
}
Step 4: Integrate Event Replay for Session Recovery
Users switch devices, clear storage, or experience network drops. The engine must reconstruct state from analytics events rather than local cache alone.
export async function recoverStateFromAnalytics(userId: string): Promise<Partial<OnboardingState>> {
const events = await fetch(`/api/analytics/onboarding/${userId}`).then(r => r.json());
const completedSteps = new Set<string>();
events.forEach((evt: { type: string; payload: { stepId: string } }) => {
if (evt.type === 'onboarding.step.completed') {
completedSteps.add(evt.payload.stepId);
}
});
return {
userId,
completedSteps,
lastSync: Date.now()
};
}
Architecture Decisions and Rationale
- Config-Driven Steps Over Hardcoded Logic: Decouples business rules from UI. Enables A/B testing, feature flags, and remote configuration without frontend deploys.
- State Machine Over Conditional Rendering: Guarantees deterministic progression. Prevents race conditions when multiple events fire simultaneously.
- Event Bus Over Direct Prop Drilling: Isolates components. Enables analytics, telemetry, and third-party integrations without coupling.
- LocalStorage + Analytics Replay: Balances offline resilience with source-of-truth verification. Local state provides instant UI; analytics provides auditability and cross-device sync.
- Category-Based Ordering: Ensures foundational steps (auth, permissions) resolve before integrations or validation. Reduces backtracking and support friction.
Pitfall Guide
-
Hardcoding Step Sequences in Components
Embedding if (step === 1) show A else if (step === 2) show B ties progression to UI state. When API latency or role changes occur, the sequence breaks. Use a state machine that evaluates readiness conditions independently of render cycles.
-
Ignoring Session Persistence and State Recovery
Users close tabs, switch networks, or log out mid-flow. Without localStorage fallback and analytics event replay, the engine resets to step one. This triggers abandonment. Always persist completed steps and reconcile with server-side event logs on mount.
-
Optimizing for Completion Rate Over TTFV
A 90% completion rate is meaningless if users spend 20 minutes clicking through irrelevant steps. Track time-to-first-value, not checkbox completion. Skip steps that do not directly enable core functionality.
-
Forcing Mobile/Web Parity Without Adaptation
Desktop onboarding relies on hover states, side panels, and multi-column layouts. Mobile requires bottom sheets, progressive disclosure, and touch targets. Share the state machine and config, but render context-aware UI. Never force identical step order across form factors.
-
Skipping Accessibility and Keyboard Navigation
Onboarding modals trap focus, ignore Escape key, or lack ARIA live regions. This blocks assistive technology users and violates compliance standards. Every step must be navigable via keyboard, announce state changes to screen readers, and respect prefers-reduced-motion.
-
Not Handling Authenticated vs. Unauthenticated Flows
Assuming all users start from a clean session ignores SSO provisioning delays, enterprise directory sync, and guest access. Evaluate readiness conditions against actual system state, not assumed roles. Defer steps that depend on external provisioning.
-
Over-Measuring and Under-Acting on Analytics
Tracking 40 micro-events without defining success criteria creates noise. Instrument only completion events, skip triggers, and fallback activations. Review funnel drop-off weekly, not daily. Iterate on step relevance, not button color.
Best Practices from Production:
- Run onboarding steps behind feature flags to isolate rollout impact.
- Implement idempotent completion handlers to prevent duplicate analytics.
- Use background prefetching for step assets to eliminate render latency.
- Log state transitions to a dedicated telemetry channel for replay debugging.
- Deprecate steps that consistently show >40% skip rate within 14 days.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup MVP (<10k users) | Config-driven linear wizard with localStorage persistence | Fastest implementation, low engineering overhead, sufficient for early validation | Low |
| Enterprise SaaS (role-based, SSO) | Adaptive event-driven engine with analytics replay | Handles permission delays, multi-tenant configs, and cross-device sessions | Medium |
| Mobile-First Consumer App | Progressive contextual flow with offline fallback | Reduces cognitive load on small screens, maintains state across app switches | Medium-High |
| Regulated Industry (FinTech/Health) | Deterministic state machine with audit logging | Guarantees compliance, enables replay debugging, prevents state drift | High |
Configuration Template
{
"onboarding": {
"version": "2.1",
"steps": [
{
"id": "auth_verify",
"title": "Verify Identity",
"category": "auth",
"prerequisites": [],
"readinessConditions": "state.role !== 'guest'",
"completionEvent": "user.auth.verified",
"uiComponent": "AuthVerificationStep",
"fallbackBehavior": "block"
},
{
"id": "workspace_setup",
"title": "Configure Workspace",
"category": "configuration",
"prerequisites": ["auth_verify"],
"readinessConditions": "state.environment === 'web' && !state.completedSteps.has('workspace_setup')",
"completionEvent": "workspace.created",
"uiComponent": "WorkspaceSetupStep",
"fallbackBehavior": "skip"
},
{
"id": "integration_connect",
"title": "Connect Data Source",
"category": "integration",
"prerequisites": ["workspace_setup"],
"readinessConditions": "state.role.includes('admin')",
"completionEvent": "integration.connected",
"uiComponent": "IntegrationConnectStep",
"fallbackBehavior": "defer"
}
],
"analytics": {
"trackTTFV": true,
"skipThreshold": 0.4,
"replayEndpoint": "/api/analytics/onboarding/:userId"
}
}
}
Quick Start Guide
- Initialize the Orchestrator: Import
OnboardingOrchestrator, pass the JSON config and initial user state. Mount it at the app root before routing.
- Register UI Components: Map
uiComponent keys to lazy-loaded React/Vue components. Ensure each emits onComplete when its action finishes.
- Attach Analytics Listeners: Hook
completionEvent strings to your telemetry SDK. Fire onboarding.step.completed with step ID and timestamp.
- Enable Session Recovery: On app mount, call
recoverStateFromAnalytics() and merge results into the orchestrator state before evaluating the first step.
- Deploy Behind Flag: Wrap the onboarding renderer in a feature flag. Roll out to 10% of new users, monitor TTFV and completion rate, then expand.