Skip to main content

How to save product tour progress with localStorage in React

Persist product tour state across page refreshes using Tour Kit's built-in localStorage adapter. Resume tours, track completions, and handle SSR safely.

DomiDex
DomiDexCreator of Tour Kit
April 7, 20269 min read
Share
How to save product tour progress with localStorage in React

How to save product tour progress with localStorage in React

A user clicks through three steps of your onboarding tour, gets pulled into a Slack thread, and refreshes the page. The tour starts over from step one. They close it and never come back.

Most React tour libraries treat persistence as an afterthought. You're left wiring up localStorage.setItem calls in event handlers, wrestling with SSR errors in Next.js, and hoping your key naming doesn't collide with another feature. Tour Kit handles this differently. Persistence is a first-class config option that works out of the box with localStorage, sessionStorage, cookies, or any custom adapter.

Tour Kit is a headless React product tour library (core package under 8KB gzipped, 0 runtime dependencies). Its @tour-kit/core package includes a usePersistence hook and a PersistenceConfig type that manage step position, completion tracking, and "don't show again" state across sessions.

By the end of this tutorial, you'll have a tour that remembers where users left off, skips completed tours on return visits, and handles SSR without errors.

npm install @tourkit/core @tourkit/react

What you'll build

A 4-step onboarding tour that saves its position to localStorage on every step change. When a user refreshes or returns the next day, the tour picks up exactly where they left off. You'll add completion tracking so finished tours don't reappear, a "don't show again" checkbox, SSR-safe storage for Next.js 14+ and Remix, and a pluggable adapter system that swaps localStorage for sessionStorage, cookies, or a server backend in 1 line of config. Total implementation: under 50 lines of tour-specific code on top of Tour Kit's built-in usePersistence hook.

Prerequisites

  • React 18.2+ or React 19
  • A working React project (Vite, Next.js, or Create React App)
  • Basic familiarity with React hooks (useState, useEffect)
  • TypeScript recommended but not required

What Tour Kit persists by default

Tour Kit's persistence system stores five pieces of state in localStorage under the tourkit prefix. The default PersistenceConfig enables persistence automatically, so you don't need to write any localStorage.setItem calls yourself.

Here's what gets saved:

KeyWhat it storesConfig flag
tourkit:step:{tourId}Current step index (for mid-tour resume)rememberStep: true
tourkit:completedJSON array of completed tour IDstrackCompleted: true
tourkit:skippedJSON array of skipped tour IDstrackCompleted: true
tourkit:dontShow:{tourId}Boolean flag for user opt-outdontShowAgain: true

The tourkit: prefix comes from keyPrefix in the config. Change it to avoid collisions if your app already uses keys starting with tourkit.

Total storage per tour: roughly 200 bytes. Compare that to the 5-10MB localStorage limit per domain. Even an app with 50 tours would use about 10KB, or 0.1% of the available space.

Step 1: Enable persistence in your tour provider

Tour Kit enables persistence by default. If you haven't changed the defaults, you already have localStorage persistence. But explicit configuration is better than hoping defaults are correct.

// src/providers/TourProvider.tsx
import { TourKitProvider } from '@tourkit/react'

export function AppTourProvider({ children }: { children: React.ReactNode }) {
  return (
    <TourKitProvider
      config={{
        persistence: {
          enabled: true,
          storage: 'localStorage',
          keyPrefix: 'tourkit',
          rememberStep: true,
          trackCompleted: true,
          dontShowAgain: false,
        },
      }}
    >
      {children}
    </TourKitProvider>
  )
}

That dontShowAgain: false is intentional. We'll enable it in Step 4 with a checkbox UI. Turning it on without a way for users to control it creates a dead end where tours disappear forever with no way to replay them.

Step 2: Build a tour that resumes from the last step

The usePersistence hook exposes getLastStep and saveStep. Tour Kit calls saveStep internally when the step changes, so you don't need to wire it up manually. But you do need to use getLastStep to set the initial step when the tour mounts.

// src/components/OnboardingTour.tsx
import { useTour, usePersistence } from '@tourkit/react'

const steps = [
  { id: 'welcome', target: '#dashboard-header', title: 'Welcome', content: 'This is your dashboard.' },
  { id: 'sidebar', target: '#sidebar-nav', title: 'Navigation', content: 'Find all your projects here.' },
  { id: 'create', target: '#create-button', title: 'Create a project', content: 'Click here to get started.' },
  { id: 'settings', target: '#settings-link', title: 'Settings', content: 'Customize your workspace.' },
]

export function OnboardingTour() {
  const persistence = usePersistence()
  const lastStep = persistence.getLastStep('onboarding')
  const completedTours = persistence.getCompletedTours()

  // Don't show if already completed
  if (completedTours.includes('onboarding')) {
    return null
  }

  return (
    <Tour
      id="onboarding"
      steps={steps}
      defaultStep={lastStep ?? 0}
      onComplete={() => persistence.markCompleted('onboarding')}
      onSkip={() => persistence.markSkipped('onboarding')}
    />
  )
}

Open your browser's DevTools, navigate to Application > Local Storage, and you'll see tourkit:step:onboarding update as you click through steps. Refresh the page mid-tour. The tour picks up where you left off.

Step 3: Handle SSR without the "window is not defined" error

If you're using Next.js or Remix, you've probably hit this: ReferenceError: window is not defined. The server tries to access localStorage during the first render and crashes.

Tour Kit's createStorageAdapter handles this automatically. When typeof window === 'undefined', it returns a no-op adapter that silently ignores all read/write calls. No useEffect wrapper needed, no dynamic imports, no 'use client' directive on your config file.

As Josh W. Comeau noted in his guide on persisting React state in localStorage: "If your app is server-rendered with a framework like Next.js or Remix, you'll get an error if you try using this hook as-is."

Tour Kit's adapter pattern avoids this entirely. The createNoopStorage function returns a storage object where getItem always returns null and setItem does nothing:

// This is what Tour Kit does internally (you don't need to write this)
function createNoopStorage(): Storage {
  return {
    getItem: () => null,
    setItem: () => {},
    removeItem: () => {},
  }
}

On the server, the tour initializes with default values. On the client, React hydrates and the real localStorage adapter takes over. No flash, no mismatch.

For Next.js App Router specifically, mark your tour component with 'use client'. The tour needs DOM access for element targeting anyway:

// src/components/OnboardingTour.tsx
'use client'

import { useTour, usePersistence } from '@tourkit/react'
// ... rest of the component from Step 2

Step 4: Add a "don't show again" checkbox

Users should control whether a tour comes back. A hidden "don't show again" flag with no UI is a dark pattern. Here's a checkbox that persists the user's preference.

// src/components/TourStepCard.tsx
import { usePersistence } from '@tourkit/react'

interface TourStepCardProps {
  tourId: string
  title: string
  content: string
  isLastStep: boolean
  onNext: () => void
  onClose: () => void
}

export function TourStepCard({
  tourId, title, content, isLastStep, onNext, onClose,
}: TourStepCardProps) {
  const persistence = usePersistence()
  const [dontShow, setDontShow] = React.useState(false)

  const handleClose = () => {
    if (dontShow) {
      persistence.setDontShowAgain(tourId, true)
    }
    onClose()
  }

  return (
    <div role="dialog" aria-label={title}>
      <h3>{title}</h3>
      <p>{content}</p>
      <label>
        <input
          type="checkbox"
          checked={dontShow}
          onChange={(e) => setDontShow(e.target.checked)}
        />
        Don't show this again
      </label>
      <div>
        <button onClick={handleClose}>Close</button>
        {!isLastStep && <button onClick={onNext}>Next</button>}
      </div>
    </div>
  )
}

Then check the flag before rendering the tour:

// In your tour component
const dontShowAgain = persistence.getDontShowAgain('onboarding')
if (dontShowAgain || completedTours.includes('onboarding')) {
  return null
}

Open DevTools again. Check the box and close the tour. You'll see tourkit:dontShow:onboarding set to "true".

Step 5: Switch storage backends without changing your components

The PersistenceConfig.storage field accepts 'localStorage', 'sessionStorage', 'cookie', or any object matching the Storage interface. Swap the backend in one place and every usePersistence call across your app uses the new adapter.

// sessionStorage: data clears when the tab closes
// Good for preview/demo tours that shouldn't persist
<TourKitProvider config={{ persistence: { storage: 'sessionStorage' } }}>

// Cookie storage: works when localStorage is blocked
// Some enterprise browsers disable localStorage
<TourKitProvider config={{ persistence: { storage: 'cookie' } }}>

// Custom adapter: sync with your backend
const serverAdapter: Storage = {
  getItem: async (key) => {
    const res = await fetch(`/api/tour-state/${key}`)
    return res.ok ? res.text() : null
  },
  setItem: async (key, value) => {
    await fetch(`/api/tour-state/${key}`, {
      method: 'PUT',
      body: value,
    })
  },
  removeItem: async (key) => {
    await fetch(`/api/tour-state/${key}`, { method: 'DELETE' })
  },
}

<TourKitProvider config={{ persistence: { storage: serverAdapter } }}>

The Storage interface is three methods: getItem, setItem, removeItem. Each can return a Promise for async backends. This is how you'd add multi-device sync since localStorage only works on a single browser.

As the flows.sh blog notes: "If a user leaves the tour midway, give them the option to resume it later. This respects their time and workflow interruptions." For signed-in users across devices, a server adapter makes that possible.

Common issues and troubleshooting

"Tour restarts from step 0 after deployment"

You changed your steps array (added, removed, or reordered steps) but the persisted step index still points to the old position. A user who was on step 3 of a 4-step tour is now on step 3 of a 5-step tour with different content.

Fix this by resetting persistence when your tour version changes:

// src/components/OnboardingTour.tsx
const TOUR_VERSION = 2 // Bump when steps change

export function OnboardingTour() {
  const persistence = usePersistence()

  React.useEffect(() => {
    const stored = localStorage.getItem('tourkit:version:onboarding')
    if (stored && Number(stored) < TOUR_VERSION) {
      persistence.reset('onboarding')
    }
    localStorage.setItem('tourkit:version:onboarding', String(TOUR_VERSION))
  }, [persistence])

  // ... rest of the component
}

"localStorage is full" or QuotaExceededError

localStorage has a ~5-10MB limit per domain (DigitalOcean). Tour Kit stores roughly 200 bytes per tour, so you won't hit this from tour data alone. But if your app stores other data in localStorage, the quota can fill up.

Tour Kit's safeJSONParse gracefully handles read failures. Writes will silently fail when the quota is exceeded.

Wrap your provider with error handling if this is a concern:

const safeStorage: Storage = {
  getItem: (key) => {
    try { return localStorage.getItem(key) }
    catch { return null }
  },
  setItem: (key, value) => {
    try { localStorage.setItem(key, value) }
    catch (e) { console.warn('Tour Kit: storage full, progress not saved', e) }
  },
  removeItem: (key) => {
    try { localStorage.removeItem(key) }
    catch { /* noop */ }
  },
}

"Tour shows in every tab after I dismiss it"

localStorage fires native storage events when modified from a different tab. But those events don't trigger React state updates automatically. If a user completes a tour in Tab A, Tab B still shows it until the next render cycle.

Add a cross-tab listener to your tour component:

React.useEffect(() => {
  const handler = (event: StorageEvent) => {
    if (event.key?.startsWith('tourkit:')) {
      // Force re-check of completion state
      window.location.reload() // Simple but effective
    }
  }
  window.addEventListener('storage', handler)
  return () => window.removeEventListener('storage', handler)
}, [])

A smarter approach uses a state setter instead of reload(), but for most apps the page refresh is fine. The storage event fires only from other tabs (never the current tab), so there's no risk of infinite loops.

When localStorage isn't enough

localStorage works well for single-device, single-browser persistence. That covers most product tour use cases. But there are situations where you need something else:

  • Multi-device users need server-side persistence (use the custom Storage adapter from Step 5)
  • Incognito / private browsing clears localStorage on window close, same behavior as sessionStorage
  • Enterprise environments sometimes block localStorage entirely, so fall back to cookies or a server adapter
  • Compliance requirements (GDPR, CCPA) may require consent before storing even non-PII like tour state

Tour Kit doesn't store personal data in tour persistence, just step indices and boolean flags. But check with your legal team if you're in a regulated industry. Tour Kit is a React-only library (React 18+), so if your team doesn't write React, this approach won't apply directly.

And like any newer project, Tour Kit has a smaller community than React Joyride (603K weekly downloads on npm as of April 2026) or Shepherd.js (4,200 GitHub stars).

Next steps

You now have a tour that survives refreshes, tracks completions, and lets users opt out. A few things to build from here:

  • Analytics gating: Use getCompletedTours() to fire analytics events only on first completion, preventing duplicate tracking
  • Conditional tours by user role: Combine persistence with the conditional tour pattern to show different tours to admins vs. regular users
  • Tour versioning: The troubleshooting fix above is manual. For production apps, store a schema version alongside each tour and auto-invalidate on deploy

We measured localStorage read/write times on a 2023 MacBook Pro (Chrome 124, M2 chip): getItem averaged 0.02ms, setItem averaged 0.04ms. Even on a low-end Android device (Moto G Power, Chrome 122), reads stayed under 0.1ms. These numbers are consistent with web.dev performance guidance on synchronous storage APIs.

Check the Tour Kit persistence docs for the full API reference, or explore the live demo on StackBlitz to see persistence in action.

FAQ

How does Tour Kit save product tour progress across page refreshes?

Tour Kit's usePersistence hook writes tourkit:step:{tourId} to localStorage on every step change. When the page reloads, getLastStep() reads the stored index and the tour resumes from that position. The storage backend is configurable: swap to sessionStorage, cookies, or a custom server adapter in one line of config.

Does localStorage persistence work with Next.js server-side rendering?

Yes. Tour Kit's createStorageAdapter detects server environments where window is undefined and returns a no-op adapter. No hydration mismatch, no typeof window guards in your code. Mark your tour component with 'use client' in Next.js App Router since tours need DOM access for element targeting.

What happens if a user clears their browser data?

All localStorage data is deleted, including tour progress. Tour Kit handles this gracefully: getLastStep() returns null, getCompletedTours() returns an empty array, and the tour starts fresh. For critical flows, implement a custom Storage adapter that syncs with your server using async getItem and setItem methods.

Can I store product tour state in something other than localStorage?

Tour Kit's PersistenceConfig accepts 'localStorage' (default), 'sessionStorage', 'cookie', or any object with getItem, setItem, and removeItem methods. Session storage works for ephemeral preview tours. Cookie storage handles environments where localStorage is blocked. Custom adapters with fetch give you server-side persistence.

Does persisting tour progress affect page performance?

No measurable impact. Tour Kit writes to localStorage only on step changes, a synchronous call that takes under 1ms. As Josh W. Comeau explains, localStorage causes issues with rapid state changes, but tour steps are deliberate user actions. Tour Kit stores roughly 200 bytes per tour.


Ready to try userTourKit?

$ pnpm add @tour-kit/react