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:
State Management - Finite state machines handle tour logic. Ensures the tour flow is predictable and debuggable.
React Integration - Components and hooks provide the UI layer. Handles the tour lifecycle and provides the tour context to the components.
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 EventsKey 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_step1to 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 ComponentComponent Hierarchy:
TourProvider: Manages tour selection and lifecycle and provides context
TourMachine: Initializes state machine when tour starts. The "heavy" part of the library, and only loaded when the tour is active.
TourOverlay: Renders spotlight and positioning
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
Simple States: Regular tour steps
step1 → step2 → step3Async States: Steps with sub-states (substates are generated by the machine generator)
step_pending → step_processing → step_successNavigation 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
}Navigation System
Page Detection
The library handles navigation in two ways:
Auto-Navigation: Automatically navigates to required pages if auto-advance is enabled
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
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 tourNEXT: Move forwardPREV: Move backwardSKIP_TOUR: Exit tourEND_TOUR: Complete tourPAGE_CHANGED: Page navigation occurredAUTO_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:
Automatic Flipping: If a card would render outside the viewport on the specified side, it automatically flips to the opposite side
Shift Along Axis: Cards shift along their placement axis to stay within viewport bounds
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
Lazy Component Loading: Tour components only render when active
Memoization: Heavy calculations are memoized
Event Debouncing: Resize/scroll events are throttled
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
State Persistence: Save/restore tour progress
Plugin System: Extensible architecture for features
Accessibility: Screen reader support and ARIA
Mobile Optimizations: Touch gestures and responsive design
Next Steps
Check Pull Requests for contribution process
Last updated