Back to Journal
SaaS Engineering

How to Build SaaS Onboarding Flows Using React

Step-by-step tutorial for building SaaS Onboarding Flows with React, from project setup through deployment.

Muneer Puthiya Purayil 25 min read

This tutorial builds a complete SaaS onboarding flow in React from scratch — covering state management, step orchestration, a progress checklist UI, contextual tooltips, analytics tracking, and auto-completion triggers. The result is a production-ready system that guides users through activation while staying out of their way.

Prerequisites

  • React 18+
  • TypeScript 5+
  • A backend API (Express, Fastify, or similar)
  • Tailwind CSS
  • A state management approach (we will use React Context + useReducer)

Architecture Overview

The onboarding system has four layers:

  1. Flow definitions — static templates describing steps, dependencies, and conditions
  2. State machine — a reducer that manages step transitions and flow lifecycle
  3. API integration — hooks that sync state with the backend
  4. UI components — checklist widget, contextual tooltips, and completion celebrations
1┌──────────────────────────────────────────────────┐
2│ React App │
3│ ┌─────────────┐ ┌────────────┐ ┌───────────┐ │
4│ │ Checklist │ │ Spotlight │ │ Triggers │ │
5│ │ Widget │ │ Tooltips │ │ (auto- │ │
6│ └──────┬──────┘ └─────┬──────┘ │ complete) │ │
7│ │ │ └─────┬─────┘ │
8│ └───────────────┼───────────────┘ │
9│ ┌─────▼──────┐ │
10│ │ Onboarding │ │
11│ │ Context │ │
12│ └─────┬──────┘ │
13│ ┌─────▼──────┐ │
14│ │ useReducer │ │
15│ │ + API sync │ │
16│ └────────────┘ │
17└──────────────────────────────────────────────────┘
18
19 ┌────▼────┐
20 │ Backend │
21 │ API │
22 └─────────┘
23 

Step 1: Type Definitions

Start with the core types that flow through the entire system.

typescript
1// src/onboarding/types.ts
2 
3export type StepStatus = 'locked' | 'available' | 'in_progress' | 'completed' | 'skipped';
4export type FlowStatus = 'not_started' | 'in_progress' | 'completed' | 'dismissed';
5 
6export interface OnboardingStep {
7 id: string;
8 stepKey: string;
9 title: string;
10 description: string;
11 status: StepStatus;
12 required: boolean;
13 sortOrder: number;
14 dependsOn: string[];
15 completedAt: string | null;
16 metadata: Record<string, unknown>;
17}
18 
19export interface OnboardingFlow {
20 id: string;
21 userId: string;
22 flowType: string;
23 variant: string;
24 status: FlowStatus;
25 steps: OnboardingStep[];
26 startedAt: string;
27 completedAt: string | null;
28 dismissedAt: string | null;
29}
30 
31export interface FlowTemplate {
32 flowType: string;
33 steps: StepTemplate[];
34}
35 
36export interface StepTemplate {
37 stepKey: string;
38 title: string;
39 description: string;
40 required: boolean;
41 sortOrder: number;
42 dependsOn: string[];
43}
44 

Step 2: State Machine with useReducer

The reducer handles all state transitions for the onboarding flow. Using a reducer instead of multiple useState calls ensures transitions are atomic and predictable.

typescript
1// src/onboarding/reducer.ts
2import type { OnboardingFlow, OnboardingStep, StepStatus } from './types';
3 
4type OnboardingState = {
5 flow: OnboardingFlow | null;
6 loading: boolean;
7 error: string | null;
8};
9 
10type OnboardingAction =
11 | { type: 'SET_FLOW'; payload: OnboardingFlow }
12 | { type: 'SET_LOADING'; payload: boolean }
13 | { type: 'SET_ERROR'; payload: string }
14 | { type: 'COMPLETE_STEP'; payload: { stepKey: string } }
15 | { type: 'SKIP_STEP'; payload: { stepKey: string } }
16 | { type: 'DISMISS' }
17 | { type: 'RESET' };
18 
19export const initialState: OnboardingState = {
20 flow: null,
21 loading: true,
22 error: null,
23};
24 
25function unlockDependentSteps(
26 steps: OnboardingStep[],
27 completedStepKey: string
28): OnboardingStep[] {
29 return steps.map(step => {
30 if (step.status !== 'locked') return step;
31 
32 const allDepsMet = step.dependsOn.every(dep => {
33 const depStep = steps.find(s => s.stepKey === dep);
34 return (
35 depStep?.status === 'completed' ||
36 depStep?.status === 'skipped' ||
37 dep === completedStepKey
38 );
39 });
40 
41 if (allDepsMet) {
42 return { ...step, status: 'available' as StepStatus };
43 }
44 
45 return step;
46 });
47}
48 
49function checkFlowComplete(steps: OnboardingStep[]): boolean {
50 return steps
51 .filter(s => s.required)
52 .every(s => s.status === 'completed');
53}
54 
55export function onboardingReducer(
56 state: OnboardingState,
57 action: OnboardingAction
58): OnboardingState {
59 switch (action.type) {
60 case 'SET_FLOW':
61 return { flow: action.payload, loading: false, error: null };
62 
63 case 'SET_LOADING':
64 return { ...state, loading: action.payload };
65 
66 case 'SET_ERROR':
67 return { ...state, error: action.payload, loading: false };
68 
69 case 'COMPLETE_STEP': {
70 if (!state.flow) return state;
71 const { stepKey } = action.payload;
72 
73 let updatedSteps = state.flow.steps.map(step =>
74 step.stepKey === stepKey
75 ? { ...step, status: 'completed' as StepStatus, completedAt: new Date().toISOString() }
76 : step
77 );
78 
79 updatedSteps = unlockDependentSteps(updatedSteps, stepKey);
80 const isComplete = checkFlowComplete(updatedSteps);
81 
82 return {
83 ...state,
84 flow: {
85 ...state.flow,
86 steps: updatedSteps,
87 status: isComplete ? 'completed' : state.flow.status,
88 completedAt: isComplete ? new Date().toISOString() : null,
89 },
90 };
91 }
92 
93 case 'SKIP_STEP': {
94 if (!state.flow) return state;
95 const { stepKey } = action.payload;
96 
97 let updatedSteps = state.flow.steps.map(step =>
98 step.stepKey === stepKey
99 ? { ...step, status: 'skipped' as StepStatus, completedAt: new Date().toISOString() }
100 : step
101 );
102 
103 updatedSteps = unlockDependentSteps(updatedSteps, stepKey);
104 
105 return {
106 ...state,
107 flow: { ...state.flow, steps: updatedSteps },
108 };
109 }
110 
111 case 'DISMISS':
112 if (!state.flow) return state;
113 return {
114 ...state,
115 flow: {
116 ...state.flow,
117 status: 'dismissed',
118 dismissedAt: new Date().toISOString(),
119 },
120 };
121 
122 case 'RESET':
123 return initialState;
124 
125 default:
126 return state;
127 }
128}
129 

Step 3: API Client

A thin API client that wraps onboarding endpoint calls.

typescript
1// src/onboarding/api.ts
2import type { OnboardingFlow } from './types';
3 
4const API_BASE = import.meta.env.VITE_API_URL;
5 
6async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
7 const response = await fetch(`${API_BASE}${path}`, {
8 ...options,
9 credentials: 'include',
10 headers: {
11 'Content-Type': 'application/json',
12 ...options.headers,
13 },
14 });
15 
16 if (!response.ok) {
17 const error = await response.json().catch(() => ({ message: 'Request failed' }));
18 throw new Error(error.message);
19 }
20 
21 return response.json();
22}
23 
24export const onboardingApi = {
25 getProgress: () =>
26 request<OnboardingFlow>('/api/onboarding/progress'),
27 
28 completeStep: (stepKey: string, metadata: Record<string, unknown> = {}) =>
29 request<OnboardingFlow>('/api/onboarding/complete', {
30 method: 'POST',
31 body: JSON.stringify({ stepKey, metadata }),
32 }),
33 
34 skipStep: (stepKey: string) =>
35 request<OnboardingFlow>('/api/onboarding/skip', {
36 method: 'POST',
37 body: JSON.stringify({ stepKey }),
38 }),
39 
40 dismiss: () =>
41 request<void>('/api/onboarding/dismiss', { method: 'POST' }),
42};
43 

Step 4: Onboarding Context and Provider

The context combines the reducer with API calls and exposes a clean interface to consuming components.

typescript
1// src/onboarding/context.tsx
2import {
3 createContext,
4 useContext,
5 useReducer,
6 useEffect,
7 useCallback,
8 type ReactNode,
9} from 'react';
10import { onboardingReducer, initialState } from './reducer';
11import { onboardingApi } from './api';
12import type { OnboardingStep, OnboardingFlow } from './types';
13 
14interface OnboardingContextValue {
15 flow: OnboardingFlow | null;
16 currentStep: OnboardingStep | null;
17 completionPct: number;
18 loading: boolean;
19 error: string | null;
20 isComplete: boolean;
21 isDismissed: boolean;
22 completeStep: (stepKey: string, metadata?: Record<string, unknown>) => Promise<void>;
23 skipStep: (stepKey: string) => Promise<void>;
24 dismiss: () => Promise<void>;
25}
26 
27const OnboardingContext = createContext<OnboardingContextValue | null>(null);
28 
29export function OnboardingProvider({ children }: { children: ReactNode }) {
30 const [state, dispatch] = useReducer(onboardingReducer, initialState);
31 
32 useEffect(() => {
33 let cancelled = false;
34 
35 const load = async () => {
36 try {
37 const flow = await onboardingApi.getProgress();
38 if (!cancelled) dispatch({ type: 'SET_FLOW', payload: flow });
39 } catch (err) {
40 if (!cancelled) dispatch({ type: 'SET_ERROR', payload: (err as Error).message });
41 }
42 };
43 
44 load();
45 return () => { cancelled = true; };
46 }, []);
47 
48 const completeStep = useCallback(async (
49 stepKey: string,
50 metadata: Record<string, unknown> = {}
51 ) => {
52 // Optimistic update
53 dispatch({ type: 'COMPLETE_STEP', payload: { stepKey } });
54 
55 try {
56 const updatedFlow = await onboardingApi.completeStep(stepKey, metadata);
57 dispatch({ type: 'SET_FLOW', payload: updatedFlow });
58 } catch (err) {
59 // Rollback on failure — re-fetch
60 const flow = await onboardingApi.getProgress();
61 dispatch({ type: 'SET_FLOW', payload: flow });
62 }
63 }, []);
64 
65 const skipStep = useCallback(async (stepKey: string) => {
66 dispatch({ type: 'SKIP_STEP', payload: { stepKey } });
67 
68 try {
69 const updatedFlow = await onboardingApi.skipStep(stepKey);
70 dispatch({ type: 'SET_FLOW', payload: updatedFlow });
71 } catch {
72 const flow = await onboardingApi.getProgress();
73 dispatch({ type: 'SET_FLOW', payload: flow });
74 }
75 }, []);
76 
77 const dismiss = useCallback(async () => {
78 dispatch({ type: 'DISMISS' });
79 await onboardingApi.dismiss();
80 }, []);
81 
82 const steps = state.flow?.steps ?? [];
83 const doneCount = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length;
84 
85 const value: OnboardingContextValue = {
86 flow: state.flow,
87 currentStep: steps
88 .filter(s => s.status === 'available')
89 .sort((a, b) => {
90 if (a.required !== b.required) return a.required ? -1 : 1;
91 return a.sortOrder - b.sortOrder;
92 })[0] ?? null,
93 completionPct: steps.length > 0 ? Math.round((doneCount / steps.length) * 100) : 0,
94 loading: state.loading,
95 error: state.error,
96 isComplete: state.flow?.status === 'completed',
97 isDismissed: state.flow?.status === 'dismissed',
98 completeStep,
99 skipStep,
100 dismiss,
101 };
102 
103 return (
104 <OnboardingContext.Provider value={value}>
105 {children}
106 </OnboardingContext.Provider>
107 );
108}
109 
110export function useOnboarding(): OnboardingContextValue {
111 const context = useContext(OnboardingContext);
112 if (!context) throw new Error('useOnboarding must be inside OnboardingProvider');
113 return context;
114}
115 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Step 5: Checklist Widget Component

The visual checklist that floats over the application.

typescript
1// src/onboarding/components/Checklist.tsx
2import { useState } from 'react';
3import { motion, AnimatePresence } from 'motion/react';
4import { useOnboarding } from '../context';
5 
6export function OnboardingChecklist() {
7 const {
8 flow, currentStep, completionPct, loading, isComplete, isDismissed,
9 completeStep, skipStep, dismiss,
10 } = useOnboarding();
11 const [expanded, setExpanded] = useState(true);
12 
13 if (loading || !flow || isComplete || isDismissed) return null;
14 
15 return (
16 <div className="fixed bottom-4 right-4 z-50 w-80">
17 <div className="bg-white rounded-xl shadow-2xl border border-gray-200">
18 {/* Header */}
19 <button
20 onClick={() => setExpanded(e => !e)}
21 className="w-full flex items-center justify-between p-4 hover:bg-gray-50"
22 >
23 <div>
24 <p className="text-sm font-semibold text-gray-900">Getting Started</p>
25 <p className="text-xs text-gray-500">
26 {flow.steps.filter(s => s.status === 'completed').length} of{' '}
27 {flow.steps.length} complete
28 </p>
29 </div>
30 <ProgressRing percentage={completionPct} />
31 </button>
32 
33 {/* Progress bar */}
34 <div className="mx-4 h-1 bg-gray-100 rounded-full overflow-hidden">
35 <motion.div
36 className="h-full bg-blue-600 rounded-full"
37 animate={{ width: `${completionPct}%` }}
38 transition={{ duration: 0.4, ease: 'easeOut' }}
39 />
40 </div>
41 
42 {/* Steps list */}
43 <AnimatePresence>
44 {expanded && (
45 <motion.div
46 initial={{ height: 0, opacity: 0 }}
47 animate={{ height: 'auto', opacity: 1 }}
48 exit={{ height: 0, opacity: 0 }}
49 transition={{ duration: 0.2 }}
50 className="overflow-hidden"
51 >
52 <div className="p-4 space-y-1">
53 {flow.steps.map(step => (
54 <StepItem
55 key={step.id}
56 step={step}
57 isCurrent={currentStep?.stepKey === step.stepKey}
58 onComplete={() => completeStep(step.stepKey)}
59 onSkip={() => skipStep(step.stepKey)}
60 />
61 ))}
62 
63 <button
64 onClick={dismiss}
65 className="text-xs text-gray-400 hover:text-gray-600 mt-3 block"
66 >
67 Dismiss checklist
68 </button>
69 </div>
70 </motion.div>
71 )}
72 </AnimatePresence>
73 </div>
74 </div>
75 );
76}
77 
78function ProgressRing({ percentage }: { percentage: number }) {
79 const circumference = 2 * Math.PI * 14;
80 const offset = circumference - (percentage / 100) * circumference;
81 
82 return (
83 <svg width="36" height="36" className="-rotate-90">
84 <circle cx="18" cy="18" r="14" fill="none" stroke="#e5e7eb" strokeWidth="3" />
85 <motion.circle
86 cx="18" cy="18" r="14" fill="none" stroke="#3b82f6" strokeWidth="3"
87 strokeLinecap="round"
88 strokeDasharray={circumference}
89 animate={{ strokeDashoffset: offset }}
90 transition={{ duration: 0.5 }}
91 />
92 </svg>
93 );
94}
95 
96function StepItem({
97 step,
98 isCurrent,
99 onComplete,
100 onSkip,
101}: {
102 step: { stepKey: string; title: string; description: string; status: string; required: boolean };
103 isCurrent: boolean;
104 onComplete: () => void;
105 onSkip: () => void;
106}) {
107 const done = step.status === 'completed';
108 const skipped = step.status === 'skipped';
109 const locked = step.status === 'locked';
110 
111 return (
112 <div
113 className={`p-2.5 rounded-lg transition-colors ${
114 isCurrent ? 'bg-blue-50 ring-1 ring-blue-200' : ''
115 } ${locked ? 'opacity-40' : ''}`}
116 >
117 <div className="flex items-start gap-2.5">
118 {/* Status icon */}
119 <div className="mt-0.5 flex-shrink-0">
120 {done ? (
121 <svg width="18" height="18" viewBox="0 0 18 18" className="text-green-500">
122 <circle cx="9" cy="9" r="9" fill="currentColor" />
123 <path d="M5.5 9l2.5 2.5 4.5-4.5" stroke="white" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
124 </svg>
125 ) : (
126 <svg width="18" height="18" viewBox="0 0 18 18">
127 <circle cx="9" cy="9" r="8" fill="none" stroke={isCurrent ? '#3b82f6' : '#d1d5db'} strokeWidth="2" />
128 </svg>
129 )}
130 </div>
131 
132 {/* Content */}
133 <div className="flex-1 min-w-0">
134 <p className={`text-sm font-medium leading-tight ${
135 done || skipped ? 'text-gray-400 line-through' : 'text-gray-900'
136 }`}>
137 {step.title}
138 </p>
139 
140 {isCurrent && (
141 <motion.div
142 initial={{ opacity: 0, height: 0 }}
143 animate={{ opacity: 1, height: 'auto' }}
144 >
145 <p className="text-xs text-gray-500 mt-1">{step.description}</p>
146 <div className="flex items-center gap-3 mt-2">
147 <button
148 onClick={onComplete}
149 className="text-xs font-medium text-blue-600 hover:text-blue-800"
150 >
151 Mark complete
152 </button>
153 {!step.required && (
154 <button
155 onClick={onSkip}
156 className="text-xs text-gray-400 hover:text-gray-600"
157 >
158 Skip
159 </button>
160 )}
161 </div>
162 </motion.div>
163 )}
164 </div>
165 </div>
166 </div>
167 );
168}
169 

Step 6: Spotlight Tooltip Component

Highlight specific UI elements when the corresponding onboarding step is active.

typescript
1// src/onboarding/components/Spotlight.tsx
2import { useRef, type ReactNode } from 'react';
3import { motion, AnimatePresence } from 'motion/react';
4import { useOnboarding } from '../context';
5 
6interface SpotlightProps {
7 stepKey: string;
8 children: ReactNode;
9 tooltip: ReactNode;
10 placement?: 'top' | 'bottom' | 'left' | 'right';
11}
12 
13export function Spotlight({ stepKey, children, tooltip, placement = 'bottom' }: SpotlightProps) {
14 const { currentStep } = useOnboarding();
15 const active = currentStep?.stepKey === stepKey;
16 const ref = useRef<HTMLDivElement>(null);
17 
18 const placementClasses: Record<string, string> = {
19 top: 'bottom-full mb-3 left-1/2 -translate-x-1/2',
20 bottom: 'top-full mt-3 left-1/2 -translate-x-1/2',
21 left: 'right-full mr-3 top-1/2 -translate-y-1/2',
22 right: 'left-full ml-3 top-1/2 -translate-y-1/2',
23 };
24 
25 const arrowClasses: Record<string, string> = {
26 top: 'top-full left-1/2 -translate-x-1/2 border-t-gray-900 border-x-transparent border-b-transparent',
27 bottom: 'bottom-full left-1/2 -translate-x-1/2 border-b-gray-900 border-x-transparent border-t-transparent',
28 left: 'left-full top-1/2 -translate-y-1/2 border-l-gray-900 border-y-transparent border-r-transparent',
29 right: 'right-full top-1/2 -translate-y-1/2 border-r-gray-900 border-y-transparent border-l-transparent',
30 };
31 
32 return (
33 <div ref={ref} className="relative inline-block">
34 {/* Highlight ring */}
35 {active && (
36 <motion.div
37 className="absolute -inset-1.5 rounded-lg ring-2 ring-blue-400 pointer-events-none"
38 initial={{ opacity: 0 }}
39 animate={{ opacity: [0, 1, 0.7, 1] }}
40 transition={{ duration: 1.5, repeat: 2 }}
41 />
42 )}
43 
44 {children}
45 
46 {/* Tooltip */}
47 <AnimatePresence>
48 {active && (
49 <motion.div
50 className={`absolute z-50 w-60 ${placementClasses[placement]}`}
51 initial={{ opacity: 0, scale: 0.95 }}
52 animate={{ opacity: 1, scale: 1 }}
53 exit={{ opacity: 0, scale: 0.95 }}
54 transition={{ duration: 0.15 }}
55 >
56 <div className="relative bg-gray-900 text-white text-xs rounded-lg p-3 shadow-xl">
57 {tooltip}
58 <div className={`absolute w-0 h-0 border-[6px] ${arrowClasses[placement]}`} />
59 </div>
60 </motion.div>
61 )}
62 </AnimatePresence>
63 </div>
64 );
65}
66 

Step 7: Auto-Completion Hook

Automatically mark onboarding steps as complete when users perform the corresponding action through the regular product UI.

typescript
1// src/onboarding/hooks/useAutoComplete.ts
2import { useEffect, useRef } from 'react';
3import { useOnboarding } from '../context';
4 
5type ActionStepMap = Record<string, string>;
6 
7const ACTION_STEP_MAP: ActionStepMap = {
8 'project:created': 'create_project',
9 'task:created': 'create_task',
10 'workspace:created': 'create_workspace',
11 'invite:sent': 'invite_team',
12 'view:changed': 'customize_view',
13};
14 
15export function useOnboardingAutoComplete() {
16 const { flow, completeStep } = useOnboarding();
17 const flowRef = useRef(flow);
18 flowRef.current = flow;
19 
20 useEffect(() => {
21 const handler = (event: CustomEvent<{ action: string; metadata?: Record<string, unknown> }>) => {
22 const { action, metadata } = event.detail;
23 const stepKey = ACTION_STEP_MAP[action];
24 if (!stepKey) return;
25 
26 const currentFlow = flowRef.current;
27 if (!currentFlow || currentFlow.status !== 'in_progress') return;
28 
29 const step = currentFlow.steps.find(s => s.stepKey === stepKey);
30 if (!step || step.status === 'completed' || step.status === 'skipped') return;
31 
32 if (step.status === 'available' || step.status === 'in_progress') {
33 completeStep(stepKey, { ...metadata, autoCompleted: true, triggeredBy: action });
34 }
35 };
36 
37 window.addEventListener('product-action', handler as EventListener);
38 return () => window.removeEventListener('product-action', handler as EventListener);
39 }, [completeStep]);
40}
41 
42// Utility to dispatch product actions from anywhere in the app
43export function emitProductAction(action: string, metadata?: Record<string, unknown>) {
44 window.dispatchEvent(
45 new CustomEvent('product-action', { detail: { action, metadata } })
46 );
47}
48 

Usage in a product component:

typescript
1// src/components/CreateProjectModal.tsx
2import { emitProductAction } from '@/onboarding/hooks/useAutoComplete';
3 
4function CreateProjectModal({ onClose }: { onClose: () => void }) {
5 const handleSubmit = async (data: ProjectFormData) => {
6 const project = await projectApi.create(data);
7 
8 // Trigger onboarding auto-complete
9 emitProductAction('project:created', { projectId: project.id });
10 
11 onClose();
12 };
13 
14 return (
15 <form onSubmit={handleSubmit}>
16 {/* Form fields */}
17 </form>
18 );
19}
20 

Step 8: Completion Celebration

Show a brief celebration animation when the user completes all onboarding steps.

typescript
1// src/onboarding/components/CompletionCelebration.tsx
2import { useEffect, useState } from 'react';
3import { motion, AnimatePresence } from 'motion/react';
4import { useOnboarding } from '../context';
5 
6export function CompletionCelebration() {
7 const { isComplete } = useOnboarding();
8 const [show, setShow] = useState(false);
9 
10 useEffect(() => {
11 if (isComplete) {
12 setShow(true);
13 const timer = setTimeout(() => setShow(false), 4000);
14 return () => clearTimeout(timer);
15 }
16 }, [isComplete]);
17 
18 return (
19 <AnimatePresence>
20 {show && (
21 <motion.div
22 className="fixed inset-0 z-[100] flex items-center justify-center pointer-events-none"
23 initial={{ opacity: 0 }}
24 animate={{ opacity: 1 }}
25 exit={{ opacity: 0 }}
26 >
27 <motion.div
28 className="bg-white rounded-2xl shadow-2xl p-8 text-center pointer-events-auto"
29 initial={{ scale: 0.8, y: 20 }}
30 animate={{ scale: 1, y: 0 }}
31 exit={{ scale: 0.8, y: 20 }}
32 >
33 <motion.div
34 className="text-5xl mb-4"
35 animate={{ rotate: [0, -10, 10, -10, 0] }}
36 transition={{ duration: 0.5, delay: 0.3 }}
37 >
38 🎉
39 </motion.div>
40 <h2 className="text-xl font-bold text-gray-900 mb-2">
41 You are all set!
42 </h2>
43 <p className="text-sm text-gray-500 max-w-xs">
44 You have completed the setup. Your workspace is ready.
45 </p>
46 </motion.div>
47 </motion.div>
48 )}
49 </AnimatePresence>
50 );
51}
52 

Step 9: Wiring It Together

Assemble all components in your app layout.

typescript
1// src/App.tsx
2import { OnboardingProvider } from './onboarding/context';
3import { OnboardingChecklist } from './onboarding/components/Checklist';
4import { CompletionCelebration } from './onboarding/components/CompletionCelebration';
5import { useOnboardingAutoComplete } from './onboarding/hooks/useAutoComplete';
6import { Dashboard } from './pages/Dashboard';
7 
8function OnboardingShell({ children }: { children: React.ReactNode }) {
9 useOnboardingAutoComplete();
10 
11 return (
12 <>
13 {children}
14 <OnboardingChecklist />
15 <CompletionCelebration />
16 </>
17 );
18}
19 
20export default function App() {
21 return (
22 <OnboardingProvider>
23 <OnboardingShell>
24 <Dashboard />
25 </OnboardingShell>
26 </OnboardingProvider>
27 );
28}
29 

Use the Spotlight component in your dashboard:

typescript
1// src/pages/Dashboard.tsx
2import { Spotlight } from '@/onboarding/components/Spotlight';
3 
4export function Dashboard() {
5 return (
6 <div className="p-6">
7 <div className="flex items-center justify-between mb-8">
8 <h1 className="text-2xl font-bold">Dashboard</h1>
9 
10 <Spotlight
11 stepKey="create_project"
12 placement="bottom"
13 tooltip={
14 <>
15 <p className="font-semibold mb-1">Create your first project</p>
16 <p className="text-gray-300">Projects organize tasks, files, and team conversations in one place.</p>
17 </>
18 }
19 >
20 <button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
21 New Project
22 </button>
23 </Spotlight>
24 </div>
25 
26 {/* Dashboard content */}
27 </div>
28 );
29}
30 

Step 10: Testing the Onboarding Flow

typescript
1// src/onboarding/__tests__/reducer.test.ts
2import { describe, it, expect } from 'vitest';
3import { onboardingReducer, initialState } from '../reducer';
4import type { OnboardingFlow } from '../types';
5 
6const mockFlow: OnboardingFlow = {
7 id: 'flow-1',
8 userId: 'user-1',
9 flowType: 'team',
10 variant: 'control',
11 status: 'in_progress',
12 startedAt: '2024-01-01T00:00:00Z',
13 completedAt: null,
14 dismissedAt: null,
15 steps: [
16 {
17 id: 's1', stepKey: 'create_workspace', title: 'Create workspace',
18 description: '', status: 'available', required: true, sortOrder: 1,
19 dependsOn: [], completedAt: null, metadata: {},
20 },
21 {
22 id: 's2', stepKey: 'create_project', title: 'Create project',
23 description: '', status: 'locked', required: true, sortOrder: 2,
24 dependsOn: ['create_workspace'], completedAt: null, metadata: {},
25 },
26 {
27 id: 's3', stepKey: 'invite_team', title: 'Invite team',
28 description: '', status: 'locked', required: false, sortOrder: 3,
29 dependsOn: ['create_workspace'], completedAt: null, metadata: {},
30 },
31 ],
32};
33 
34describe('onboardingReducer', () => {
35 it('unlocks dependent steps when a step is completed', () => {
36 const stateWithFlow = { flow: mockFlow, loading: false, error: null };
37 
38 const result = onboardingReducer(stateWithFlow, {
39 type: 'COMPLETE_STEP',
40 payload: { stepKey: 'create_workspace' },
41 });
42 
43 const projectStep = result.flow!.steps.find(s => s.stepKey === 'create_project');
44 const inviteStep = result.flow!.steps.find(s => s.stepKey === 'invite_team');
45 
46 expect(projectStep!.status).toBe('available');
47 expect(inviteStep!.status).toBe('available');
48 });
49 
50 it('marks flow as completed when all required steps are done', () => {
51 const flowWithOneRequired: OnboardingFlow = {
52 ...mockFlow,
53 steps: [
54 { ...mockFlow.steps[0], status: 'completed', completedAt: '2024-01-01' },
55 { ...mockFlow.steps[1], status: 'available' },
56 { ...mockFlow.steps[2], status: 'available' },
57 ],
58 };
59 
60 const result = onboardingReducer(
61 { flow: flowWithOneRequired, loading: false, error: null },
62 { type: 'COMPLETE_STEP', payload: { stepKey: 'create_project' } },
63 );
64 
65 expect(result.flow!.status).toBe('completed');
66 });
67 
68 it('sets flow to dismissed state', () => {
69 const result = onboardingReducer(
70 { flow: mockFlow, loading: false, error: null },
71 { type: 'DISMISS' },
72 );
73 
74 expect(result.flow!.status).toBe('dismissed');
75 expect(result.flow!.dismissedAt).toBeTruthy();
76 });
77});
78 

Conclusion

This React onboarding implementation gives you a self-contained system that handles the full lifecycle: flow initialization, step dependency resolution, optimistic state updates, auto-completion from product actions, and a polished UI with contextual guidance.

The reducer-based state machine is the architectural backbone. Every state transition goes through a single, testable function, which makes it straightforward to verify that step dependencies resolve correctly and that flow completion triggers at the right time. Optimistic updates in the context layer ensure the UI feels instant even when the API round-trip takes a few hundred milliseconds.

The auto-completion pattern via custom DOM events decouples onboarding from product features. Product components dispatch events without knowing whether onboarding exists, and the onboarding system listens without coupling to specific components. This separation means you can add new steps or change the flow structure without modifying any product code.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026