Architecture

This document describes the architecture and design patterns used in Tourista.

Overview

Tourista is built on a state machine architecture, providing predictable and debuggable tour flows. The library separates concerns into distinct layers:

  1. State Management - Finite state machines handle tour logic. Ensures the tour flow is predictable and debuggable.

  2. React Integration - Components and hooks provide the UI layer. Handles the tour lifecycle and provides the tour context to the components.

  3. Type System - TypeScript ensures type safety and developer experience. Provides type inference for the tour config and events, improving the developer experience.

Core Architecture

State Machine Layer

The heart of the library is the state machine, built on @tinystack/machine:

generateTourMachine()

   StateMachine

      Actor

   Tour Events

Key Components:

  • Machine Generator (tourMachineGenerator.ts): Creates "formal" state machine configurations (machine config) from tour configs (easily readable and understandable).

  • State Management: Each tour step becomes a state in the machine. There are also some internal states like navigatingTo_step1 to handle page transitions. Those are not part of the tour steps and are not part of the tour config.

  • Event System: Typed events drive state transitions

React Layer

TourProvider (Context)

   TourMachine (Core)

   TourOverlay (UI)

    Card Component

Component Hierarchy:

  1. TourProvider: Manages tour selection and lifecycle and provides context

  2. TourMachine: Initializes state machine when tour starts. The "heavy" part of the library, and only loaded when the tour is active.

  3. TourOverlay: Renders spotlight and positioning

  4. Card Components: Display tour content

Key Design Patterns

1. Lazy Initialization

The state machine is only created when a tour starts:

// TourMachineReact.tsx
useEffect(() => {
  if (!tourMachine || tourMachine.config.id !== tourConfig.id) {
    const machineConfig = generateTourMachine(tourConfig);
    tourMachine = new StateMachine(machineConfig);
  }
  // ...
}, [tourConfig]);

Benefits:

  • Zero performance impact when tours aren't active

  • Memory efficient

  • Non-invasive for users

2. Global Actor Pattern

A single global actor manages the active tour:

// Global references
export let tourActor: TourActor | null = null;
export let tourMachine: TTourMachine | null = null;

Rationale:

  • Only one tour can be active at a time, at least visually active (the user can see it). So there is no need to have multiple actors.

  • Simplifies state synchronization

  • Enables external control via exported references

3. Hook Separation

Two hooks serve different purposes:

  • useTourContext(): Tour management (start/stop, skip, complete)

  • useTour(tourId): Tour control (navigation, state)

// Management
const { startTour } = useTourContext();

// Control
const tour = useTour('my-tour');
tour.nextStep();

4. Type Inference

TypeScript's type system provides compile-time safety:

// Type inference from configuration
type States = ExtractStates<typeof tourConfig>;
type Events = ExtractTourEvents<typeof tourConfig>;

State Machine Design

State Types

  1. Simple States: Regular tour steps

    step1 → step2 → step3
  2. Async States: Steps with sub-states (substates are generated by the machine generator)

    step_pending → step_processing → step_success
  3. Navigation States: Intermediate states for page transitions (generated by the machine generator when navigation is allowed)

    navigatingTo_step1 → step1

Event Flow

USER ACTION → EVENT → STATE MACHINE → STATE CHANGE → UI UPDATE
     ↑                                                    ↓
     └────────────── React Re-render ←───────────────────┘

Context Structure

Each state (step, though not always the user defined ones) maintains context:

interface TourContext {
  tourId: string;
  currentPage: string;
  targetElement?: string;
  title: string;
  content: string;
  autoAdvanceTimer?: any;
  // any future context will be added here
}

Page Detection

The library handles navigation in two ways:

  1. Auto-Navigation: Automatically navigates to required pages if auto-advance is enabled

  2. Navigation Detection: Detects manual navigation and adjusts the tour state accordingly

That way, we get more reliable tours, where we can defined declaratively several ways to reach the next step.

// Auto-navigation
if (targetPage && targetPage !== pathname) {
  router.push(targetPage);
}

// Detection
useEffect(() => {
  tourActor.send({
    type: 'PAGE_CHANGED', // if the machine is in a "navigational" state ("navigatingTo_step1", "navigatingTo_step2", etc.), this event will be sent, triggering a transition
    page: pathname,
    tourId: tourConfig.id,
  });
}, [pathname]);

Navigation states prevent race conditions:

Current State: step1 (page: /home)
User clicks Next → navigatingTo_step2
Page changes → step2 (page: /dashboard)

Event System

Base Events

All tours support these events (in different steps - states depending on the tour config):

  • START_TOUR: Initialize tour

  • NEXT: Move forward

  • PREV: Move backward

  • SKIP_TOUR: Exit tour

  • END_TOUR: Complete tour

  • PAGE_CHANGED: Page navigation occurred

  • AUTO_ADVANCE: Timer-based progression

Custom Events

Async steps can define custom events, allowing for custom interactive patterns (i.e. wait the user to provide some data, validate a form. Just need to send the success event to continue the tour).

This allows complete flexibility in how the user reaches the next step, without overburdening the tour config or your codebase. Just send the event when the user has completed the step.

{
  type: 'async',
  id: 'fetch_data',
  page: '/',
  content: {
    pending: {
      title: 'Fetching data',
      content: 'Fetching data...',
    },
  },
  events: {
    start: 'FETCH_DATA', // Optionally customized event names.
    success: 'DATA_LOADED',
    failed: 'DATA_ERROR'
  }
}

Component Architecture

TourProvider

  • Maintains active tour ID

  • Provides tour configuration to children

  • Handles tour lifecycle (start/end)

TourMachine

  • Creates and manages state machine actor

  • Handles keyboard navigation

  • Manages page navigation

  • Cleans up on unmount

TourOverlay

  • Calculates spotlight positioning for target elements

  • Renders overlay with customizable styles

  • Smart Card Positioning: Uses Floating UI to position cards intelligently

    • Automatically flips position when near viewport edges

    • Prevents cards from being cut off or hidden

    • Maintains specified distance from target element

    • Supports manual positioning override (top, bottom, left, right)

  • Handles click-outside behavior for tour dismissal

Positioning System (Floating UI)

The library uses @floating-ui/react for intelligent card positioning to ensure tour cards are always visible and well-positioned.

How It Works

Floating UI prevents collision with viewport edges by:

  1. Automatic Flipping: If a card would render outside the viewport on the specified side, it automatically flips to the opposite side

  2. Shift Along Axis: Cards shift along their placement axis to stay within viewport bounds

  3. Maintained Distance: Keeps consistent spacing from the target element

Implementation

// In TourOverlay component
import { useFloating, offset, flip, shift } from '@floating-ui/react';

const { refs, floatingStyles } = useFloating({
  placement: cardPositioning.side, // 'top' | 'bottom' | 'left' | 'right'
  middleware: [
    offset(cardPositioning.distancePx), // Distance from target
    flip(), // Auto-flip when no space
    shift({ padding: 5 }), // Keep within viewport
  ],
});

Benefits

  • Always Visible: Cards never appear off-screen or get cut off

  • Smart Positioning: Automatically finds the best position

  • Responsive: Adapts to viewport changes and scrolling

  • User-Friendly: Prevents frustrating partially-visible cards

Customization

Users can still override automatic positioning:

<TourMachine
  cardPositioning={{
    floating: true, // Enable floating behavior
    side: 'bottom', // Preferred side
    distancePx: 10, // Distance from element
  }}
/>

Performance Considerations

Optimization Strategies

  1. Lazy Component Loading: Tour components only render when active

  2. Memoization: Heavy calculations are memoized

  3. Event Debouncing: Resize/scroll events are throttled

  4. Selective Re-renders: useSyncExternalStore for efficient updates

Bundle Size

  • Minimal dependencies (3 runtime deps)

  • No code splitting to maintain simplicity

Extension Points

Custom Cards

Replace default card with custom component:

// components/TourProvider.tsx
'use client';

import { TourProvider as TourProviderComponent, TourMachine } from 'Tourista';

export function TourProvider({ children }: { children: React.ReactNode }) {
  return (
    <TourProviderComponent tours={tours}>
      <TourMachine customCard={MyCard} />
      {children}
    </TourProviderComponent>
  );
}

That way, you can completely customize the card UI.

Event Handlers

Hook into tour lifecycle:

// components/TourProvider.tsx
'use client';

import { TourProvider as TourProviderComponent, TourMachine } from 'Tourista';

export function TourProvider({ children }: { children: React.ReactNode }) {
  return (
    <TourProviderComponent tours={tours}>
      <TourMachine
        onComplete={handleComplete}
        onSkip={handleSkip}
        onNext={handleNext}
      />
      {children}
    </TourProviderComponent>
  );
}

Useful for analytics and custom logic. (Ps: This is the place where you put your confetti drama 🤦🏻‍♂️)

Styling

Customize overlay and positioning:

// components/TourProvider.tsx
'use client';

import { TourProvider as TourProviderComponent, TourMachine } from 'Tourista';

export function TourProvider({ children }: { children: React.ReactNode }) {
  return (
    <TourProviderComponent tours={tours}>
      <TourMachine
        overlayStyles={{ opacity: 0.8 }}
        cardPositioning={{ side: 'top' }}
      />
      {children}
    </TourProviderComponent>
  );
}

Future Architecture Considerations

Potential Improvements

  1. State Persistence: Save/restore tour progress

  2. Plugin System: Extensible architecture for features

  3. Accessibility: Screen reader support and ARIA

  4. Mobile Optimizations: Touch gestures and responsive design

Next Steps

Last updated