Persistence
Save tour progress, checklist completion, and hint dismissals across browser sessions with pluggable storage adapters
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:
| Storage | Scope | SSR Safe | Best For |
|---|---|---|---|
localStorage | Cross-session | No | Default browser storage |
sessionStorage | Session only | No | Temporary progress |
memory | Page only | Yes | SSR, testing |
| Custom adapter | Your choice | Depends | API backend, IndexedDB |
SSR Considerations
When using Server-Side Rendering (Next.js, Remix), storage APIs aren't available during initial render:
// 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
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
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enable/disable persistence |
storage | 'localStorage' | 'sessionStorage' | Storage | 'localStorage' | Storage backend |
keyPrefix | string | 'tourkit' | Key namespace prefix |
rememberStep | boolean | true | Save last viewed step |
trackCompleted | boolean | true | Track completed tours |
dontShowAgain | boolean | false | Enable "don't show again" |
Storage Keys
| Key Pattern | Type | Purpose |
|---|---|---|
{prefix}:completed | string[] | Completed tour IDs |
{prefix}:skipped | string[] | Skipped tour IDs |
{prefix}:step:{tourId} | number | Last step index |
{prefix}:dontShow:{tourId} | boolean | Don't show flag |
API Reference
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
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 1: User advances to step 3
routePersistence.save({ stepIndex: 3 })
// Tab 2: Automatically receives update via StorageEvent
// Tour UI updates to reflect step 3Expiry 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
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
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:
<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 honoredConfiguration
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
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
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
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:
| Package | Default Prefix | Recommended Custom |
|---|---|---|
| Core (tours) | tourkit | {app}:tours |
| Core (routes) | tourkit-route-state | {app}:route-state |
| Checklists | tourkit-checklists | {app}:checklists |
| Announcements | tour-kit:announcements: | {app}:announcements: |
| Adoption | tourkit-adoption | {app}:adoption |
// 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:
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:
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:
| Concern | useRoutePersistence | useFlowSession |
|---|---|---|
| Scope | All tours, all routes | The single active tour |
| Use case | "Continue this tour after I navigate" | "Resume this tour after I refresh" |
| Default storage | localStorage | sessionStorage (per-tab) |
| TTL | 24h (configurable) | 1h sessionStorage / 24h localStorage |
| Storage key | tourkit-route-state | tourkit: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
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 withcrossTab.enabled: true(see Multi-tab Tours) so a tour explicitly closed in one tab doesn't reappear when another tab reloads.
<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 exceeded —
setItemerrors are caught, logged vialogger.warn, and swallowed. - Stale schema — a blob written by an older app version (different shape, missing fields, wrong
schemaVersion) parses tonull; the bad blob is removed and the next save writes fresh. - SSR — when
windowis undefined the hook returns no-opsave/clearandsession: null.
Best Practices
- Use consistent prefixes - Namespace all keys to avoid collisions
- Handle SSR - userTourKit does this automatically, but custom code needs checks
- Respect user privacy - Provide a way to clear stored data
- Test persistence - Include persistence in your test suites
- Plan for migration - Version your persisted data structures
- Monitor storage usage - Large amounts of data can hit quotas
Related Resources
- usePersistence Hook
- useRoutePersistence Hook
- useChecklistPersistence Hook
- Cross-page Tour example — flow session resumes the right step on the right URL after a hard refresh
- Multi-tab Tours
- Storage Utilities
- Announcement Frequency Rules