TourKit
Guides

Persistence

Save tour progress, checklist completion, and hint dismissals across browser sessions with pluggable storage adapters

Persistence

User Tour Kit provides comprehensive persistence across all packages, allowing you to save user progress, dismissed announcements, checklist completion, and feature adoption data. This guide covers storage options, configuration, and best practices.

Why Persistence Matters

Without persistence:

  • Users restart onboarding tours every visit
  • Dismissed announcements reappear
  • Checklist progress is lost on refresh
  • Feature adoption tracking resets

With User Tour Kit's persistence:

  • Tours resume from where users left off
  • "Don't show again" is remembered
  • Checklists track completed tasks across sessions
  • Adoption data builds over time

Storage Options

User Tour Kit supports multiple storage backends:

StorageScopeSSR SafeBest For
localStorageCross-sessionNoDefault browser storage
sessionStorageSession onlyNoTemporary progress
memoryPage onlyYesSSR, testing
Custom adapterYour choiceDependsAPI backend, IndexedDB

SSR Considerations

When using Server-Side Rendering (Next.js, Remix), storage APIs aren't available during initial render:

SSR-safe pattern
// Tour Kit automatically handles this, but for custom code:
const storage = typeof window !== 'undefined'
  ? localStorage
  : createMemoryStorage()

All User Tour Kit persistence hooks automatically fall back to memory storage during SSR, then hydrate client-side.


Tour Persistence

The usePersistence hook saves tour progress for single-page applications.

Configuration

Setting up tour persistence
import { TourProvider } from '@tour-kit/react'

<TourProvider
  tour={myTour}
  persistence={{
    enabled: true,
    storage: 'localStorage',
    keyPrefix: 'my-app',
    rememberStep: true,
    trackCompleted: true,
    dontShowAgain: false,
  }}
>
  {children}
</TourProvider>

Configuration Options

OptionTypeDefaultDescription
enabledbooleantrueEnable/disable persistence
storage'localStorage' | 'sessionStorage' | Storage'localStorage'Storage backend
keyPrefixstring'tourkit'Key namespace prefix
rememberStepbooleantrueSave last viewed step
trackCompletedbooleantrueTrack completed tours
dontShowAgainbooleanfalseEnable "don't show again"

Storage Keys

Key PatternTypePurpose
{prefix}:completedstring[]Completed tour IDs
{prefix}:skippedstring[]Skipped tour IDs
{prefix}:step:{tourId}numberLast step index
{prefix}:dontShow:{tourId}booleanDon't show flag

API Reference

Using persistence programmatically
import { usePersistence } from '@tour-kit/core'

function TourManager() {
  const persistence = usePersistence({ keyPrefix: 'my-app' })

  // Check if user completed a tour
  const completed = persistence.getCompletedTours()
  const hasCompletedOnboarding = completed.includes('onboarding')

  // Mark tour as completed
  const handleComplete = () => {
    persistence.markCompleted('onboarding')
  }

  // Resume from last step
  const lastStep = persistence.getLastStep('onboarding')

  // Enable "don't show again"
  const handleDontShow = () => {
    persistence.setDontShowAgain('onboarding', true)
  }

  // Reset all tour data
  const handleReset = () => {
    persistence.reset() // All tours
    // or
    persistence.reset('onboarding') // Single tour
  }

  return (/* ... */)
}

Multi-Page Tour Persistence

For tours that span multiple pages/routes, use useRoutePersistence:

Configuration

Multi-page persistence setup
import { TourProvider } from '@tour-kit/react'
import { useRoutePersistence } from '@tour-kit/core'

function App() {
  const routePersistence = useRoutePersistence({
    enabled: true,
    storage: 'localStorage',
    key: 'my-app-route-state',
    syncTabs: true,
    expiryMs: 24 * 60 * 60 * 1000, // 24 hours
  })

  return (
    <TourProvider
      tour={multiPageTour}
      routePersistence={routePersistence}
    >
      {children}
    </TourProvider>
  )
}

Persisted State Structure

interface PersistedRouteState {
  tourId: string | null
  stepIndex: number
  completedTours: string[]
  skippedTours: string[]
  timestamp: number
}

Cross-Tab Synchronization

When syncTabs: true, tour state syncs across browser tabs:

Tab sync behavior
// Tab 1: User advances to step 3
routePersistence.save({ stepIndex: 3 })

// Tab 2: Automatically receives update via StorageEvent
// Tour UI updates to reflect step 3

Expiry Handling

Route state expires after expiryMs (default 24 hours):

const state = routePersistence.load()

if (state && routePersistence.isStale()) {
  // State expired, start fresh
  routePersistence.clear()
}

Checklist Persistence

The @tour-kit/checklists package persists task completion and dismissal state.

Configuration

Checklist persistence setup
import { ChecklistProvider } from '@tour-kit/checklists'

<ChecklistProvider
  checklists={myChecklists}
  persistence={{
    enabled: true,
    storage: 'localStorage',
    key: 'my-app-checklists',
  }}
>
  {children}
</ChecklistProvider>

Persisted State Structure

interface PersistedChecklistState {
  completed: Record<string, string[]>  // { checklistId: [taskId1, taskId2] }
  dismissed: string[]                   // Dismissed checklist IDs
  timestamp: number
}

Using the Hook Directly

Programmatic checklist persistence
import { useChecklistPersistence } from '@tour-kit/checklists'

function ChecklistManager() {
  const persistence = useChecklistPersistence({
    enabled: true,
    storage: 'localStorage',
    key: 'my-app-checklists',
  })

  // Save current state
  persistence.save({
    completed: {
      'onboarding': ['create-account', 'verify-email'],
      'setup': ['add-profile-photo'],
    },
    dismissed: [],
    timestamp: Date.now(),
  })

  // Load persisted state
  const state = persistence.load()

  // Clear all data
  persistence.clear()
}

API Backend Persistence

For server-side storage, use custom handlers:

API-backed checklist persistence
<ChecklistProvider
  checklists={myChecklists}
  persistence={{
    enabled: true,
    onSave: async (state) => {
      await fetch('/api/user/checklists', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(state),
      })
    },
    onLoad: async () => {
      const res = await fetch('/api/user/checklists')
      if (!res.ok) return null
      return res.json()
    },
  }}
>

Announcement Persistence

Announcements track view counts, dismissals, and frequency-based display rules.

Storage Keys

Each announcement stores state with the key pattern:

{storageKey}{announcementId}

Default: tour-kit:announcements:{id}

Persisted Data Per Announcement

interface AnnouncementPersistedState {
  viewCount: number
  lastViewedAt: string | null    // ISO date
  isDismissed: boolean
  dismissedAt: string | null     // ISO date
  dismissalReason: DismissalReason
  completedAt: string | null     // ISO date (primary action taken)
}

Frequency Rules

Frequency determines how persistence affects display:

// Show only once, ever
{ frequency: 'once' }

// Persisted: viewCount >= 1 prevents re-display
// Show once per browser session
{ frequency: 'session' }

// Not persisted - uses session state only
// Show up to 3 times total
{ frequency: { type: 'times', count: 3 } }

// Persisted: viewCount tracks displays
// Show again after 7 days
{ frequency: { type: 'interval', days: 7 } }

// Persisted: lastViewedAt determines eligibility
// Show every time (respects dismissal)
{ frequency: 'always' }

// Persisted: isDismissed still honored

Configuration

Announcement persistence setup
import { AnnouncementsProvider } from '@tour-kit/announcements'

<AnnouncementsProvider
  announcements={myAnnouncements}
  storage={localStorage}           // Or sessionStorage, null
  storageKey="my-app:announcements:"
  syncTabs={true}
>
  {children}
</AnnouncementsProvider>

Adoption Persistence

The @tour-kit/adoption package tracks feature usage over time.

Persisted State Structure

interface PersistedAdoptionState {
  version: number
  userId?: string
  features: Record<string, FeatureUsage>
  nudges: NudgeState
  updatedAt: string
}

interface FeatureUsage {
  featureId: string
  firstUsed: string | null    // ISO date
  lastUsed: string | null     // ISO date
  useCount: number
  status: 'not_started' | 'exploring' | 'adopted' | 'churned'
}

interface NudgeState {
  lastShown: string | null
  dismissed: string[]         // Permanently dismissed features
  snoozed: Record<string, string>  // featureId -> expiry date
  sessionCount: number
}

Configuration

Adoption persistence setup
import { AdoptionProvider } from '@tour-kit/adoption'

<AdoptionProvider
  features={myFeatures}
  storage={{
    type: 'localStorage',
    key: 'my-app-adoption',
  }}
>
  {children}
</AdoptionProvider>

Storage Types

// Browser localStorage (default)
storage={{ type: 'localStorage', key: 'my-key' }}

// Session storage
storage={{ type: 'sessionStorage', key: 'my-key' }}

// In-memory (SSR-safe)
storage={{ type: 'memory' }}

Adoption Status Calculation

The adoption status is calculated from persisted usage data:

function calculateStatus(usage: FeatureUsage, criteria: AdoptionCriteria) {
  if (usage.useCount === 0) return 'not_started'
  if (usage.useCount < criteria.minUses) return 'exploring'

  const daysSinceUse = daysSince(usage.lastUsed)
  if (daysSinceUse > criteria.recencyDays) return 'churned'

  return 'adopted'
}

Custom Storage Adapters

Create custom adapters for IndexedDB, API backends, or other storage:

Adapter Interface

interface Storage {
  getItem(key: string): string | null | Promise<string | null>
  setItem(key: string, value: string): void | Promise<void>
  removeItem(key: string): void | Promise<void>
}

IndexedDB Example

IndexedDB storage adapter
import { openDB } from 'idb'

const idbStorage: Storage = {
  async getItem(key: string) {
    const db = await openDB('tour-kit', 1, {
      upgrade(db) {
        db.createObjectStore('state')
      },
    })
    const value = await db.get('state', key)
    return value ? JSON.stringify(value) : null
  },

  async setItem(key: string, value: string) {
    const db = await openDB('tour-kit', 1)
    await db.put('state', JSON.parse(value), key)
  },

  async removeItem(key: string) {
    const db = await openDB('tour-kit', 1)
    await db.delete('state', key)
  },
}

// Use with any Tour Kit provider
<TourProvider
  tour={myTour}
  persistence={{ storage: idbStorage }}
>

API Backend Example

REST API storage adapter
const apiStorage: Storage = {
  async getItem(key: string) {
    const res = await fetch(`/api/tour-state/${encodeURIComponent(key)}`)
    if (!res.ok) return null
    const data = await res.json()
    return JSON.stringify(data)
  },

  async setItem(key: string, value: string) {
    await fetch(`/api/tour-state/${encodeURIComponent(key)}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: value,
    })
  },

  async removeItem(key: string) {
    await fetch(`/api/tour-state/${encodeURIComponent(key)}`, {
      method: 'DELETE',
    })
  },
}

Storage Key Namespacing

When running multiple apps or features, namespace your keys:

PackageDefault PrefixRecommended Custom
Core (tours)tourkit{app}:tours
Core (routes)tourkit-route-state{app}:route-state
Checkliststourkit-checklists{app}:checklists
Announcementstour-kit:announcements:{app}:announcements:
Adoptiontourkit-adoption{app}:adoption
Custom namespacing example
// All Tour Kit data under 'acme-app' prefix
<TourProvider persistence={{ keyPrefix: 'acme-app:tours' }}>
<ChecklistProvider persistence={{ key: 'acme-app:checklists' }}>
<AnnouncementsProvider storageKey="acme-app:announcements:">
<AdoptionProvider storage={{ key: 'acme-app:adoption' }}>

Clearing Persisted Data

Per Package

// Tours
const persistence = usePersistence()
persistence.reset() // All tours
persistence.reset('tour-id') // Single tour

// Checklists
const checklistPersistence = useChecklistPersistence()
checklistPersistence.clear()

// Route state
const routePersistence = useRoutePersistence()
routePersistence.clear()

Global Reset

For a complete reset during development or user logout:

Clear all Tour Kit data
function clearAllTourKitData(prefix = 'tourkit') {
  const keys = Object.keys(localStorage)
  keys.forEach(key => {
    if (key.startsWith(prefix) || key.startsWith('tour-kit:')) {
      localStorage.removeItem(key)
    }
  })
}

// On user logout
function handleLogout() {
  clearAllTourKitData('my-app')
  // ... other logout logic
}

Error Handling

User Tour Kit handles storage errors gracefully:

// Internal pattern - errors don't crash the app
try {
  localStorage.setItem(key, JSON.stringify(value))
} catch (error) {
  if (error.name === 'QuotaExceededError') {
    logger.warn('Storage quota exceeded, state not persisted')
  }
  // Silent failure - tour continues without persistence
}

If localStorage quota is exceeded, User Tour Kit logs a warning but continues functioning without persistence. Consider clearing old data or using a custom adapter with larger capacity.


Migration and Versioning

When your data schema changes, handle migrations:

Migration pattern for adoption data
const CURRENT_VERSION = 2

function migrateState(state: PersistedState): PersistedState {
  if (state.version === 1) {
    // v1 -> v2: Add new field
    return {
      ...state,
      version: 2,
      newField: 'default',
    }
  }
  return state
}

// In your storage adapter
async function loadWithMigration(key: string) {
  const raw = await storage.getItem(key)
  if (!raw) return null

  const state = JSON.parse(raw)
  return migrateState(state)
}

Best Practices

  1. Use consistent prefixes - Namespace all keys to avoid collisions
  2. Handle SSR - User Tour Kit does this automatically, but custom code needs checks
  3. Respect user privacy - Provide a way to clear stored data
  4. Test persistence - Include persistence in your test suites
  5. Plan for migration - Version your persisted data structures
  6. Monitor storage usage - Large amounts of data can hit quotas

On this page