✨ Stop losing hours to undocumented processes. Create SOPs in seconds with Glitter AI.

Onboarding for Single-Page Applications (SPAs)

Cover Image for Onboarding for Single-Page Applications (SPAs)

Single-page applications create some headaches for onboarding that traditional sites don't have. The DOM keeps changing on the fly, routes don't trigger page reloads, and elements might not exist yet when your tour tries to point at them. Understanding these issues and how to work around them matters if you want SPA onboarding that actually works.

This guide covers how to build reliable onboarding in React, Vue, Angular, and other modern JavaScript apps.

SPA Onboarding Challenges

Dynamic DOM

The Problem:
Most onboarding tools find elements using CSS selectors. But in SPAs, elements get created and destroyed dynamically based on application state.

Example:

// This element only exists after data loads
{data && <div id="results">{/* content */}</div>}

A tour step targeting #results fails if the tour tries to show before data arrives.

Client-Side Routing

The Problem:
SPAs don't do full page reloads when you navigate around. Standard "page load" triggers just don't fire.

Example:

// Navigation doesn't reload page
<Link to="/dashboard">Dashboard</Link>

Tours configured to start "on page load" of /dashboard may never trigger.

Component Lifecycle

The Problem:
Components mount, update, and unmount based on application logic. The element your tour is pointing at might just vanish mid-tour.

Example:

// Tab content changes, removing previous elements
<Tabs>
  <Tab name="overview">{/* content */}</Tab>
  <Tab name="details">{/* different content */}</Tab>
</Tabs>

Virtual DOM

The Problem:
React, Vue, and similar frameworks work with a virtual DOM. Element references can go stale after re-renders.

Asynchronous Loading

The Problem:
Code splitting, lazy loading, and async data fetching mean elements show up at unpredictable times.

// Component loads asynchronously
const Dashboard = lazy(() => import('./Dashboard'));

Tool Compatibility

What to Look For

SPA-Compatible Tools Need To:

  • Wait for elements to appear before trying to target them
  • Handle route changes properly
  • Have React/Vue/Angular integrations
  • Support mutation observers
  • Give you programmatic control

Strong SPA Support:

  • Appcues: Handles React/Vue well, waits for elements
  • Userpilot: Has mutation observer and route detection
  • Chameleon: Deep SPA integration built in
  • Userflow: React-specific features

Moderate Support:

  • Pendo: Works but you'll need to configure it carefully
  • UserGuiding: Getting better at SPA support

Things to Check:

  • Does their documentation cover SPA usage?
  • Do they have framework-specific examples?
  • Can you configure wait times and timeouts?
  • Is there a programmatic API?

Implementation Strategies

Strategy 1: Wait for Elements

Configure Delays:
Most tools let you set wait times before trying to show steps.

// Tool configuration
{
  step: 1,
  target: '#dashboard-chart',
  waitForElement: true,
  timeout: 5000 // Wait up to 5 seconds
}

MutationObserver Approach:

// Custom implementation
function waitForElement(selector, timeout = 5000) {
  return new Promise((resolve, reject) => {
    const element = document.querySelector(selector);
    if (element) return resolve(element);

    const observer = new MutationObserver(() => {
      const el = document.querySelector(selector);
      if (el) {
        observer.disconnect();
        resolve(el);
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true
    });

    setTimeout(() => {
      observer.disconnect();
      reject(new Error('Element not found'));
    }, timeout);
  });
}

Strategy 2: Use Stable Selectors

Data Attributes (Recommended):

// Add stable identifiers
<button data-tour="create-project">
  Create Project
</button>

// Target in tour
{
  target: '[data-tour="create-project"]'
}

Why This Works:

  • Survives code refactoring
  • Makes the intent clear
  • Won't conflict with your styling
  • Stays reliable across re-renders

Implementation Pattern:

// Create a convention
const TourTarget = ({ id, children }) => (
  <span data-tour-target={id}>
    {children}
  </span>
);

// Use throughout app
<TourTarget id="dashboard-header">
  <h1>Dashboard</h1>
</TourTarget>

Strategy 3: Route-Based Triggering

React Router Integration:

// Hook into route changes
import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';

function TourTrigger() {
  const location = useLocation();

  useEffect(() => {
    // Notify onboarding tool of route change
    if (location.pathname === '/dashboard') {
      window.onboardingTool.trigger('dashboard-tour');
    }
  }, [location]);

  return null;
}

Vue Router Integration:

// In router setup
router.afterEach((to, from) => {
  if (to.path === '/dashboard') {
    window.onboardingTool.trigger('dashboard-tour');
  }
});

Strategy 4: State-Based Triggering

React Context/Redux:

// Trigger based on app state, not DOM
useEffect(() => {
  if (user.firstLogin && projects.loaded) {
    startOnboardingTour();
  }
}, [user.firstLogin, projects.loaded]);

Why This Works:

  • Doesn't depend on DOM timing
  • Way more reliable triggers
  • You get fine-grained control over conditions

Strategy 5: Programmatic Control

Take Full Control:

// In your component
useEffect(() => {
  // When component mounts and is ready
  const startTour = async () => {
    await dataLoaded; // Wait for your data
    onboardingTool.startFlow('feature-tour');
  };

  if (shouldShowTour) {
    startTour();
  }

  return () => {
    // Cleanup if component unmounts
    onboardingTool.stopFlow();
  };
}, [shouldShowTour, dataLoaded]);

Framework-Specific Guidance

React

Best Practices:

  1. Use Refs for Stable Targeting:
function FeatureButton() {
  const buttonRef = useRef(null);

  useEffect(() => {
    // Register element with onboarding system
    if (buttonRef.current) {
      onboardingTool.registerElement('feature-button', buttonRef.current);
    }
  }, []);

  return <button ref={buttonRef}>Feature</button>;
}
  1. Handle Re-renders:
// Use key to track element identity
<div key={`tour-target-${feature.id}`} data-tour="feature-card">
  {/* content */}
</div>
  1. Coordinate with Suspense:
<Suspense fallback={<Loading />}>
  <Dashboard onReady={() => startTour()} />
</Suspense>

Vue

Best Practices:

  1. Use Template Refs:
<template>
  <button ref="createButton" @click="create">Create</button>
</template>

<script>
export default {
  mounted() {
    this.$nextTick(() => {
      // Element is definitely in DOM
      onboardingTool.registerElement('create-button', this.$refs.createButton);
    });
  }
}
</script>
  1. Watch for Readiness:
<script>
export default {
  watch: {
    dataLoaded(newVal) {
      if (newVal && this.shouldShowTour) {
        this.$nextTick(() => this.startTour());
      }
    }
  }
}
</script>

Angular

Best Practices:

  1. Use ViewChild:
@Component({...})
export class FeatureComponent implements AfterViewInit {
  @ViewChild('tourTarget') tourTarget: ElementRef;

  ngAfterViewInit() {
    onboardingTool.registerElement('feature', this.tourTarget.nativeElement);
  }
}
  1. Handle Change Detection:
ngAfterViewChecked() {
  // Called after every change detection cycle
  // Use sparingly, can impact performance
}

Common Patterns

Pattern 1: Tour Manager Service

Centralized Control:

// tourManager.js
class TourManager {
  constructor() {
    this.registeredElements = new Map();
    this.activeTour = null;
  }

  registerElement(id, element) {
    this.registeredElements.set(id, element);
    this.checkTourReadiness();
  }

  unregisterElement(id) {
    this.registeredElements.delete(id);
  }

  async startTour(tourId) {
    const tour = tours[tourId];

    // Check all required elements exist
    const missingElements = tour.steps
      .map(s => s.targetId)
      .filter(id => !this.registeredElements.has(id));

    if (missingElements.length > 0) {
      console.warn('Missing elements:', missingElements);
      return false;
    }

    this.activeTour = tour;
    this.showStep(0);
    return true;
  }

  showStep(index) {
    const step = this.activeTour.steps[index];
    const element = this.registeredElements.get(step.targetId);
    // Show tooltip/modal anchored to element
  }
}

export const tourManager = new TourManager();

Pattern 2: Ready State Hook

// useTourReady.js
function useTourReady(tourId, dependencies = []) {
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    // Check if all conditions are met
    const allDependenciesReady = dependencies.every(Boolean);
    setIsReady(allDependenciesReady);

    if (allDependenciesReady) {
      tourManager.notifyReady(tourId);
    }
  }, dependencies);

  return isReady;
}

// Usage
function Dashboard({ data }) {
  const isTourReady = useTourReady('dashboard-tour', [
    data !== null,
    document.querySelector('[data-tour="chart"]')
  ]);

  // Component renders...
}

Pattern 3: Route Guard for Tours

// TourGuard component
function TourGuard({ tourId, children }) {
  const [showTour, setShowTour] = useState(false);
  const [contentReady, setContentReady] = useState(false);

  useEffect(() => {
    if (contentReady && shouldShowTour(tourId)) {
      setShowTour(true);
      startTour(tourId);
    }
  }, [contentReady, tourId]);

  return (
    <>
      {children(() => setContentReady(true))}
      {showTour && <TourOverlay />}
    </>
  );
}

// Usage
<TourGuard tourId="dashboard">
  {(onReady) => <Dashboard onReady={onReady} />}
</TourGuard>

Troubleshooting

Element Not Found

What You'll See:

  • Tour step doesn't show up
  • Console errors complaining about missing elements
  • Tour skips steps entirely

How to Fix It:

  1. Bump up the wait timeout
  2. Double-check selector accuracy
  3. Make sure the element actually renders before the tour runs
  4. Use MutationObserver to wait for elements
  5. Add explicit ready signals from your components

Tour Breaks on Navigation

What You'll See:

  • Tour vanishes when users change routes
  • Steps show up on the wrong pages
  • Multiple tours fire at once

How to Fix It:

  1. Pause the tour on route changes
  2. Create route-specific tours instead
  3. Save and restore tour state across navigation
  4. Clean up tours when components unmount

Performance Issues

What You'll See:

  • Slow rendering
  • Janky animations
  • Memory usage creeping up

How to Fix It:

  1. Debounce your MutationObserver callbacks
  2. Limit observer scope to specific containers
  3. Use passive event listeners
  4. Clean up observers when done

Flickering/Positioning

What You'll See:

  • Tooltips jumping around
  • Elements highlighting briefly then disappearing
  • Tooltips pointing at the wrong spot

How to Fix It:

  1. Wait for layout to settle before positioning
  2. Use CSS transforms for animations
  3. Recalculate position on scroll and resize
  4. Add transition delays to smooth things out

Testing SPA Onboarding

Unit Tests

// Test tour configuration
describe('Dashboard Tour', () => {
  it('should have valid selectors', async () => {
    render(<Dashboard />);

    for (const step of dashboardTour.steps) {
      const element = await screen.findByTestId(step.targetId);
      expect(element).toBeInTheDocument();
    }
  });
});

Integration Tests

// Test tour flow
describe('Onboarding Flow', () => {
  it('should complete dashboard tour', async () => {
    render(<App />);

    // Navigate to trigger tour
    await userEvent.click(screen.getByText('Dashboard'));

    // Verify tour steps
    expect(await screen.findByText('Welcome to your dashboard')).toBeVisible();

    await userEvent.click(screen.getByText('Next'));

    expect(await screen.findByText('Create your first project')).toBeVisible();
  });
});

E2E Tests

// Cypress example
describe('Onboarding', () => {
  it('shows tour for new users', () => {
    cy.login({ isNewUser: true });
    cy.visit('/dashboard');

    cy.get('[data-tour-step="1"]').should('be.visible');
    cy.contains('Welcome').should('be.visible');

    cy.get('[data-tour-next]').click();
    cy.get('[data-tour-step="2"]').should('be.visible');
  });
});

Performance Optimization

Lazy Load Onboarding

// Don't load tour library until needed
async function initOnboarding() {
  if (userNeedsOnboarding()) {
    const { TourLibrary } = await import('./tourLibrary');
    const tour = new TourLibrary();
    tour.start();
  }
}

Minimize DOM Observation

// Bad: observe everything
observer.observe(document.body, { subtree: true, childList: true });

// Better: observe specific containers
observer.observe(
  document.getElementById('main-content'),
  { childList: true }
);

Cleanup Properly

// React cleanup pattern
useEffect(() => {
  const observer = new MutationObserver(callback);
  observer.observe(target, config);

  return () => {
    observer.disconnect();
    tourManager.cleanup();
  };
}, []);

The Bottom Line

SPA onboarding means understanding both how your application renders things and what your onboarding tool can actually handle. The challenge is bridging the gap between a dynamic DOM and static tour definitions.

Key Principles:

  1. Use stable selectors like data attributes
  2. Wait for elements to exist; don't assume they're available
  3. Trigger based on state, not just routes
  4. Clean up properly when components unmount
  5. Test across different timing scenarios

With the right implementation, SPAs can deliver onboarding just as smooth as traditional server-rendered apps. Maybe even smoother, thanks to the fine-grained control that modern frameworks give you.


Continue learning: Implementing Your First Product Tour and Building Custom Onboarding.

Frequently Asked Questions

Why is onboarding difficult in single-page applications?

SPAs present unique challenges because the DOM changes dynamically, routes do not trigger page reloads, and elements may not exist when tours expect them. Components mount and unmount based on application state, making traditional selector-based onboarding unreliable.

How do you implement product tours in React applications?

Use stable data attributes like data-tour for element targeting, leverage refs for reliable element references, coordinate with Suspense for async loading, trigger tours based on state rather than page loads, and clean up tour resources on component unmount.

What onboarding tools work best with SPAs?

Tools with strong SPA support include Appcues, Userpilot, Chameleon, and Userflow. Look for features like element wait timeouts, mutation observers, route change detection, programmatic APIs, and framework-specific integrations for React, Vue, and Angular.

How do you handle dynamic elements in JavaScript app onboarding?

Use MutationObserver to wait for elements to appear, implement stable data-attribute selectors that survive re-renders, trigger onboarding based on application state rather than DOM presence, and use programmatic control to start tours when components signal readiness.

What are best practices for SPA onboarding selectors?

Use data attributes like data-tour='feature-name' instead of CSS class selectors. Data attributes survive refactoring, clearly indicate onboarding intent, do not conflict with styling, and work reliably across component re-renders in React, Vue, and Angular applications.

Onboarding for Single-Page Applications (SPAs) | AdoptKit