Skip to main content
userTourKit
Guides

Persistence

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

domidex01Published

userTourKit 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 userTourKit'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

userTourKit 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
// userTourKit automatically handles this, but for custom code:
const storage = typeof window !== 'undefined'
  ? localStorage
  : createMemoryStorage()

All userTourKit 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 userTourKit 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 userTourKit 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 userTourKit 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

userTourKit 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, userTourKit 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)
}

Flow Session

useFlowSession is a tour-scoped session — it remembers which tour the user was in and what step they reached, so a hard reload puts them back exactly where they were. It runs in parallel with useRoutePersistence (which handles multi-tour, cross-route state) but takes a different shape:

ConcernuseRoutePersistenceuseFlowSession
ScopeAll tours, all routesThe single active tour
Use case"Continue this tour after I navigate""Resume this tour after I refresh"
Default storagelocalStoragesessionStorage (per-tab)
TTL24h (configurable)1h sessionStorage / 24h localStorage
Storage keytourkit-route-statetourkit:flow:active

It's opt-in via routePersistence.flowSession. Existing apps that don't pass the field keep their current behavior bit-for-bit.

Quick start

Reload-resume tour
import { TourProvider } from '@tour-kit/core'

<TourProvider
  tours={[onboardingTour]}
  routePersistence={{
    enabled: true,
    flowSession: { storage: 'sessionStorage' },
  }}
>
  {children}
</TourProvider>

That's it — start a tour, hard-reload the page, and it resumes at the same step. Saves are throttled (200ms trailing edge) so a rapid burst of step changes coalesces into one storage write per window.

Choosing storage

  • sessionStorage (default) — perfect for the common "user accidentally hit refresh" case. Cleared automatically when the tab closes.
  • localStorage — survives a full browser quit. Pair it with crossTab.enabled: true (see Multi-tab Tours) so a tour explicitly closed in one tab doesn't reappear when another tab reloads.
Cross-session resume
<TourProvider
  routePersistence={{
    enabled: true,
    flowSession: { storage: 'localStorage', ttlMs: 12 * 60 * 60 * 1000 }, // 12h
    crossTab: { enabled: true },
  }}
>

TTL & expiry

Stale sessions are filtered on read — useFlowSession checks Date.now() - lastUpdatedAt > ttlMs and removes the blob if it's expired. Defaults: 1 hour for sessionStorage, 24 hours for localStorage. Pass ttlMs: 0 to disable expiry.

Failure modes

useFlowSession is designed not to crash the tour:

  • Quota exceededsetItem errors are caught, logged via logger.warn, and swallowed.
  • Stale schema — a blob written by an older app version (different shape, missing fields, wrong schemaVersion) parses to null; the bad blob is removed and the next save writes fresh.
  • SSR — when window is undefined the hook returns no-op save / clear and session: null.

Best Practices

  1. Use consistent prefixes - Namespace all keys to avoid collisions
  2. Handle SSR - userTourKit 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