}
| { type: 'feature_flag'; flag: string }
| { type: 'user_segment'; segment: string }
| { type: 'custom'; fn: (state: OnboardingState) => boolean };
export interface OnboardingState {
currentStepId: string | null;
completedSteps: string[];
metadata: Record<string, any>;
startedAt: number;
version: string;
}
export interface OnboardingConfig {
steps: OnboardingStep[];
startStep: string;
completionAction: () => void;
}
#### 2. The Adaptive Engine
The engine evaluates conditions to resolve the current step. This prevents hardcoded `if/else` chains in UI components.
```typescript
// engine/adaptive-engine.ts
export class AdaptiveEngine {
private config: OnboardingConfig;
private state: OnboardingState;
constructor(config: OnboardingConfig, initialState: OnboardingState) {
this.config = config;
this.state = initialState;
}
resolveNextStep(): OnboardingStep | null {
const currentIndex = this.config.steps.findIndex(
s => s.id === this.state.currentStepId
);
// Start from current or find start step
const startIndex = currentIndex >= 0 ? currentIndex : 0;
const startStep = this.config.steps.find(s => s.id === this.config.startStep);
// If state is fresh, jump to start
if (!this.state.currentStepId && startStep) {
this.state.currentStepId = startStep.id;
return startStep;
}
// Iterate forward, skipping steps based on conditions
for (let i = startIndex; i < this.config.steps.length; i++) {
const step = this.config.steps[i];
if (this.state.completedSteps.includes(step.id)) continue;
if (step.conditions && this.evaluateConditions(step.conditions)) {
this.state.currentStepId = step.id;
return step;
}
}
// Completion
return null;
}
private evaluateConditions(conditions: StepCondition[]): boolean {
return conditions.every(cond => {
switch (cond.type) {
case 'has_data':
return !!this.state.metadata[cond.field];
case 'feature_flag':
return this.checkFeatureFlag(cond.flag);
case 'user_segment':
return this.isUserInSegment(cond.segment);
case 'custom':
return cond.fn(this.state);
default:
return true;
}
});
}
// Mock integrations for feature flags and segments
private checkFeatureFlag(flag: string): boolean {
// Integrate with LaunchDarkly, Unleash, or custom provider
return window.__FEATURE_FLAGS__[flag] ?? false;
}
private isUserInSegment(segment: string): boolean {
// Integrate with analytics or user profile service
return window.__USER_SEGMENTS__.includes(segment);
}
markComplete(stepId: string, metadata?: Record<string, any>) {
if (!this.state.completedSteps.includes(stepId)) {
this.state.completedSteps.push(stepId);
}
if (metadata) {
Object.assign(this.state.metadata, metadata);
}
this.persistState();
}
private persistState() {
// Sync to backend and local storage
localStorage.setItem('onboarding_state', JSON.stringify(this.state));
// analytics.track('onboarding_step_completed', { stepId: this.state.currentStepId });
}
}
3. React Integration Hook
Encapsulate the engine in a hook to provide a clean API for components.
// hooks/useOnboarding.ts
import { useState, useEffect, useCallback } from 'react';
import { AdaptiveEngine, OnboardingState, OnboardingConfig } from '../types';
export function useOnboarding(config: OnboardingConfig) {
const [engine] = useState(() => {
const saved = localStorage.getItem('onboarding_state');
const initialState: OnboardingState = saved
? JSON.parse(saved)
: { currentStepId: null, completedSteps: [], metadata: {}, startedAt: Date.now(), version: '1.0' };
return new AdaptiveEngine(config, initialState);
});
const [currentStep, setCurrentStep] = useState(() => engine.resolveNextStep());
useEffect(() => {
const next = engine.resolveNextStep();
setCurrentStep(next);
}, [engine]);
const completeStep = useCallback((metadata?: Record<string, any>) => {
if (!currentStep) return;
engine.markComplete(currentStep.id, metadata);
setCurrentStep(engine.resolveNextStep());
}, [engine, currentStep]);
const reset = useCallback(() => {
localStorage.removeItem('onboarding_state');
engine['state'] = { currentStepId: null, completedSteps: [], metadata: {}, startedAt: Date.now(), version: '1.0' };
setCurrentStep(engine.resolveNextStep());
}, [engine]);
return {
currentStep,
completeStep,
isComplete: currentStep === null,
state: engine['state'],
reset
};
}
4. Implementation in Application
The hook allows the main application to render onboarding dynamically without coupling logic to views.
// components/OnboardingContainer.tsx
import { useOnboarding } from '../hooks/useOnboarding';
import { StepProfile, StepIntegration, StepDashboard } from './steps';
const CONFIG = {
startStep: 'profile',
completionAction: () => console.log('Onboarding complete'),
steps: [
{
id: 'profile',
component: StepProfile,
conditions: [{ type: 'custom', fn: (s) => !s.metadata.hasProfile }]
},
{
id: 'integration',
component: StepIntegration,
conditions: [
{ type: 'feature_flag', flag: 'show_integration_step' },
{ type: 'custom', fn: (s) => !s.metadata.hasIntegration }
]
},
{
id: 'dashboard',
component: StepDashboard,
// No conditions = always show if reached
}
]
};
export function OnboardingContainer() {
const { currentStep, completeStep, isComplete } = useOnboarding(CONFIG);
if (isComplete) return null;
if (!currentStep) return null; // Should not happen if isComplete is false
const StepComponent = currentStep.component;
return (
<div className="onboarding-overlay">
<StepComponent
onComplete={completeStep}
metadata={useOnboarding(CONFIG).state.metadata}
/>
</div>
);
}
Architecture Rationale
- Separation of Concerns: The
AdaptiveEngine contains all logic. Components are dumb renderers. This enables unit testing of flow logic independent of UI.
- Performance: Condition evaluation is synchronous and fast. No blocking network calls during step resolution. Feature flag checks should be cached.
- Extensibility: New step types or conditions can be added by extending the
StepCondition union and the evaluateConditions switch without modifying existing step components.
- Resilience: State persistence ensures users never lose progress. The
version field in state allows for migration strategies when onboarding flows change.
Pitfall Guide
1. The "Walled Garden" Anti-Pattern
Mistake: Blocking access to core product features until onboarding is complete.
Impact: Increases friction and reduces perceived value. Users may abandon if they cannot explore.
Best Practice: Use Progressive Disclosure. Allow users to access the product while onboarding hints or overlays guide them. Onboarding should be an overlay or a side-panel, not a hard gate, unless security/compliance requires it.
2. State Loss on Navigation
Mistake: Storing onboarding state in component local state or Redux store that resets on route change.
Impact: Users lose progress when refreshing or navigating away, leading to frustration and drop-off.
Best Practice: Implement dual persistence. Write to localStorage on every transition for immediate recovery. Sync to a backend endpoint asynchronously for cross-device continuity.
3. Hardcoded Flow Logic
Mistake: Embedding if (step === 2) showModal() logic directly in UI components.
Impact: Flow changes require code deployments. A/B testing is impossible. Technical debt accumulates rapidly.
Best Practice: Externalize flow definitions into configuration objects or JSON schemas. Use the decision engine pattern described in the Core Solution.
4. Ignoring "Time to Value" Metrics
Mistake: Optimizing for "Onboarding Completion Rate" rather than "Activation."
Impact: Users complete the flow but fail to use the product. High completion rates mask poor UX.
Best Practice: Track Time to First Action (TTFA) and Activation Rate. If completion is high but activation is low, the onboarding is teaching the wrong things or is too long. Optimize steps that directly contribute to activation.
5. Over-Engineering the State Machine
Mistake: Creating a state machine with hundreds of states and complex transitions for simple flows.
Impact: Unnecessary complexity, harder debugging, and performance overhead.
Best Practice: Keep the state machine flat. Use conditions to skip steps rather than creating complex branching graphs. A linear sequence with conditional omission is usually sufficient and more maintainable than a mesh of states.
6. Lack of Error Recovery
Mistake: Assuming step completion always succeeds. Not handling API failures during data submission.
Impact: Users get stuck in a "completed" state that is actually broken, or they see generic errors.
Best Practice: Implement idempotent step completion. If a step involves an API call, the state should only advance after success. If it fails, show a retry mechanism without resetting the entire flow. Store partial data to allow resume.
7. Analytics Blindness
Mistake: Tracking only "step viewed" and "step completed."
Impact: Cannot diagnose why users drop off. Missing context on user intent.
Best Practice: Track granular events: step_viewed, step_skipped, step_completed, step_error, and time_on_step. Include metadata like user segment, traffic source, and device type. This data is essential for optimizing the decision engine.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| B2B SaaS with complex setup | Adaptive Wizard with Progress Tracker | Users need guidance but have varying needs. Adaptive steps reduce time for advanced users while guiding novices. | Medium (Engineering effort for engine and config) |
| Consumer Mobile App | Progressive Disclosure with Tooltips | High friction kills conversion. Allow immediate usage with contextual hints. | Low (UI overlays, minimal logic) |
| High-Security Enterprise | Friction-Heavy Linear Flow | Compliance requires mandatory steps (SSO, MFA, Policy Acceptance). Cannot skip. | Low (Standard flow, high verification cost) |
| API-First Developer Tool | Self-Serve with Interactive Docs | Developers prefer autonomy. Onboarding should be a "Get Started" guide with copy-paste snippets. | Low (Docs integration, minimal UI) |
| Marketplace (Two-Sided) | Role-Based Split Onboarding | Buyers and sellers have different activation paths. Dynamic routing based on signup intent is critical. | High (Complex logic, dual flows) |
Configuration Template
Copy this template to define a scalable onboarding configuration. This JSON structure can be fetched from a CMS or backend to enable remote updates.
{
"version": "1.2.0",
"startStep": "welcome",
"completionEvent": "onboarding_completed",
"steps": [
{
"id": "welcome",
"component": "WelcomeModal",
"priority": 1,
"conditions": [],
"analytics": { "track": true, "label": "welcome_step" }
},
{
"id": "profile_setup",
"component": "ProfileForm",
"priority": 2,
"conditions": [
{ "type": "has_data", "field": "user.fullName" }
],
"analytics": { "track": true, "label": "profile_step" }
},
{
"id": "integration_github",
"component": "GithubConnect",
"priority": 3,
"conditions": [
{ "type": "feature_flag", "flag": "enable_github_oauth" },
{ "type": "user_segment", "segment": "developer" }
],
"analytics": { "track": true, "label": "github_step" }
},
{
"id": "dashboard_tour",
"component": "TourOverlay",
"priority": 4,
"conditions": [],
"analytics": { "track": true, "label": "tour_step" }
}
]
}
Quick Start Guide
-
Install Dependencies:
npm install xstate @xstate/react # Optional: If using XState instead of custom engine
# Or use the custom implementation from Core Solution
-
Define Your Schema:
Create types/onboarding.ts and engine/adaptive-engine.ts based on the Core Solution code.
-
Create Configuration:
Add config/onboarding.json with your initial steps and conditions.
-
Implement Hook:
Add hooks/useOnboarding.ts and wrap your application shell.
-
Verify:
Run the app, complete steps, refresh page, and confirm state persistence. Check analytics dashboard for events.
By treating user onboarding as an engineered subsystem rather than a static UI overlay, development teams can significantly reduce churn, improve activation rates, and maintain agility through remote configuration and adaptive logic. The investment in a robust onboarding architecture pays dividends in reduced support costs and higher lifetime value.