Skip to main content

Managing tour state with Zustand: a practical guide

Build product tour state management with Zustand in React. Step navigation, persistence, multi-tour coordination, and accessibility-driven state patterns.

DomiDex
DomiDexCreator of Tour Kit
April 7, 20268 min read
Share
Managing tour state with Zustand: a practical guide

Managing tour state with Zustand: a practical guide

Product tours have more state than you'd expect. Current step, completion status per tour, dismissed tooltips, user progress that survives page reloads. React Context handles simple cases, but once you're coordinating an onboarding flow, feature discovery hints, and a changelog tour at the same time, context providers start fighting each other for re-renders.

Zustand fits this problem well. It ships at ~1.2KB gzipped (bundlephobia), needs no Provider wrapper, and its selector pattern means your tour tooltip won't re-render your entire dashboard. As of April 2026, Zustand sits at roughly 20 million weekly npm downloads, surpassing Redux Toolkit at ~10 million (PkgPulse, 2026).

By the end of this tutorial, you'll have a typed Zustand store managing multi-step tours with persistence, conditional branching, and accessibility state. Tour Kit handles the rendering and positioning. Zustand owns the state.

npm install @tourkit/core @tourkit/react zustand

Prerequisites

  • React 18.2+ or React 19
  • TypeScript 5+
  • A working React project (Next.js, Vite, or Remix all work)
  • Basic familiarity with Zustand (you've created at least one store before)

What you'll build

You'll create a typed Zustand store that tracks step progression across multiple product tours, persists user completion status to localStorage between sessions, and exposes ARIA-relevant state so screen readers can announce tour progress. The store drives Tour Kit's headless components while you keep full control of rendering and styling.

Step 1: Define the tour state shape

Product tour state is more than a single currentStep number. You need to track which tours exist in your app, which one is currently active, which steps each user has already completed, and whether a given tour was dismissed or finished naturally. Getting the type definitions right prevents a class of bugs where components assume state shapes that don't exist. Start with the types.

// src/stores/tour-types.ts
export type TourStatus = 'idle' | 'active' | 'completed' | 'dismissed'

export interface TourState {
  id: string
  currentStep: number
  totalSteps: number
  status: TourStatus
  completedSteps: number[]
}

export interface TourStore {
  tours: Record<string, TourState>
  activeTourId: string | null

  // Actions
  registerTour: (id: string, totalSteps: number) => void
  startTour: (id: string) => void
  advanceStep: (id: string) => void
  goToStep: (id: string, step: number) => void
  dismissTour: (id: string) => void
  completeTour: (id: string) => void
  resetTour: (id: string) => void
}

Notice the actions are verbs describing intent (advanceStep, dismissTour), not generic setters (setCurrentStep). This is the pattern TkDodo recommends: "model actions as events, not setters" (Working with Zustand). It makes the store self-documenting and prevents callers from putting the store into invalid states.

Step 2: Create the Zustand store

Now build the store. Each action enforces its own invariants. You can't advance past the last step. You can't start a tour that doesn't exist.

// src/stores/tour-store.ts
import { create } from 'zustand'
import type { TourStore } from './tour-types'

export const useTourStore = create<TourStore>((set, get) => ({
  tours: {},
  activeTourId: null,

  registerTour: (id, totalSteps) => {
    set((state) => ({
      tours: {
        ...state.tours,
        [id]: {
          id,
          currentStep: 0,
          totalSteps,
          status: 'idle',
          completedSteps: [],
        },
      },
    }))
  },

  startTour: (id) => {
    const tour = get().tours[id]
    if (!tour) return

    set((state) => ({
      activeTourId: id,
      tours: {
        ...state.tours,
        [id]: { ...tour, status: 'active', currentStep: 0 },
      },
    }))
  },

  advanceStep: (id) => {
    const tour = get().tours[id]
    if (!tour || tour.status !== 'active') return

    const nextStep = tour.currentStep + 1

    if (nextStep >= tour.totalSteps) {
      get().completeTour(id)
      return
    }

    set((state) => ({
      tours: {
        ...state.tours,
        [id]: {
          ...tour,
          currentStep: nextStep,
          completedSteps: [...tour.completedSteps, tour.currentStep],
        },
      },
    }))
  },

  goToStep: (id, step) => {
    const tour = get().tours[id]
    if (!tour || step < 0 || step >= tour.totalSteps) return

    set((state) => ({
      tours: {
        ...state.tours,
        [id]: { ...tour, currentStep: step },
      },
    }))
  },

  dismissTour: (id) => {
    const tour = get().tours[id]
    if (!tour) return

    set((state) => ({
      activeTourId: state.activeTourId === id ? null : state.activeTourId,
      tours: {
        ...state.tours,
        [id]: { ...tour, status: 'dismissed' },
      },
    }))
  },

  completeTour: (id) => {
    const tour = get().tours[id]
    if (!tour) return

    set((state) => ({
      activeTourId: state.activeTourId === id ? null : state.activeTourId,
      tours: {
        ...state.tours,
        [id]: {
          ...tour,
          status: 'completed',
          completedSteps: Array.from(
            { length: tour.totalSteps },
            (_, i) => i
          ),
        },
      },
    }))
  },

  resetTour: (id) => {
    const tour = get().tours[id]
    if (!tour) return

    set((state) => ({
      tours: {
        ...state.tours,
        [id]: {
          ...tour,
          currentStep: 0,
          status: 'idle',
          completedSteps: [],
        },
      },
    }))
  },
}))

The store is about 70 lines. No Provider, no reducer boilerplate, no action creators. That's Zustand's pitch, and it holds up for tour state.

Step 3: Write atomic selectors

Subscribing to the full Zustand store with useTourStore() triggers a re-render on every state change, even ones your component doesn't care about. The fix is atomic selectors: small hooks that each read exactly one derived value from the store, so a progress bar only re-renders when progress changes and the overlay only re-renders when the tour starts or ends.

// src/stores/tour-selectors.ts
import { useTourStore } from './tour-store'

export function useActiveTour() {
  return useTourStore((state) => {
    const id = state.activeTourId
    return id ? state.tours[id] : null
  })
}

export function useTourProgress(tourId: string) {
  return useTourStore((state) => {
    const tour = state.tours[tourId]
    if (!tour) return { current: 0, total: 0, percent: 0 }
    return {
      current: tour.currentStep + 1,
      total: tour.totalSteps,
      percent: Math.round(
        ((tour.currentStep + 1) / tour.totalSteps) * 100
      ),
    }
  })
}

export function useTourStatus(tourId: string) {
  return useTourStore((state) => state.tours[tourId]?.status ?? 'idle')
}

export function useTourActions() {
  return useTourStore((state) => ({
    startTour: state.startTour,
    advanceStep: state.advanceStep,
    goToStep: state.goToStep,
    dismissTour: state.dismissTour,
    completeTour: state.completeTour,
    resetTour: state.resetTour,
  }))
}

useActiveTour() returns the full tour object only when the active tour changes. useTourProgress() returns derived values that a progress bar needs. useTourActions() returns stable function references that never cause re-renders.

One subtlety: useTourProgress creates a new object on every call. In practice this is fine because the values are primitives Zustand compares correctly. But if you're returning arrays or nested objects from selectors, reach for useShallow from zustand/shallow to prevent false positives.

Step 4: Connect Zustand to Tour Kit

Tour Kit's headless components don't care where state lives. You pass step data as props and call callbacks when users interact. The Zustand store drives everything.

// src/components/ProductTour.tsx
import { TourProvider, TourStep, TourOverlay } from '@tourkit/react'
import { useActiveTour, useTourActions } from '../stores/tour-selectors'
import { useTourStore } from '../stores/tour-store'
import { useEffect } from 'react'

const ONBOARDING_STEPS = [
  { target: '#sidebar-nav', title: 'Navigation', content: 'Browse your projects here.' },
  { target: '#search-bar', title: 'Search', content: 'Find anything across your workspace.' },
  { target: '#user-menu', title: 'Settings', content: 'Manage your account and preferences.' },
]

export function ProductTour() {
  const tour = useActiveTour()
  const { advanceStep, dismissTour, registerTour } = useTourActions()

  useEffect(() => {
    registerTour('onboarding', ONBOARDING_STEPS.length)
  }, [registerTour])

  if (!tour || tour.id !== 'onboarding') return null

  const step = ONBOARDING_STEPS[tour.currentStep]

  return (
    <TourProvider>
      <TourOverlay />
      <TourStep
        target={step.target}
        placement="bottom"
        onNext={() => advanceStep('onboarding')}
        onDismiss={() => dismissTour('onboarding')}
      >
        <div className="rounded-lg bg-white p-4 shadow-lg">
          <p className="font-medium">{step.title}</p>
          <p className="mt-1 text-sm text-gray-600">{step.content}</p>
          <div className="mt-3 flex justify-between">
            <span className="text-xs text-gray-400">
              Step {tour.currentStep + 1} of {tour.totalSteps}
            </span>
            <button
              onClick={() => advanceStep('onboarding')}
              className="rounded bg-blue-600 px-3 py-1 text-sm text-white"
            >
              {tour.currentStep < tour.totalSteps - 1 ? 'Next' : 'Finish'}
            </button>
          </div>
        </div>
      </TourStep>
    </TourProvider>
  )
}

Because Tour Kit is headless, the JSX above is your design. Swap Tailwind classes for your design system tokens. The store doesn't care how things render.

Step 5: Add persistence with Zustand middleware

Users shouldn't restart a tour they already finished. Zustand's persist middleware saves the store to localStorage automatically.

// src/stores/tour-store.ts (updated)
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { TourStore } from './tour-types'

export const useTourStore = create<TourStore>()(
  persist(
    (set, get) => ({
      // ... same store logic as Step 2
      tours: {},
      activeTourId: null,
      registerTour: (id, totalSteps) => {
        // Skip if already registered with same step count
        const existing = get().tours[id]
        if (existing && existing.totalSteps === totalSteps) return

        set((state) => ({
          tours: {
            ...state.tours,
            [id]: {
              id,
              currentStep: 0,
              totalSteps,
              status: 'idle',
              completedSteps: [],
            },
          },
        }))
      },
      startTour: (id) => {
        const tour = get().tours[id]
        if (!tour || tour.status === 'completed') return
        set((state) => ({
          activeTourId: id,
          tours: {
            ...state.tours,
            [id]: { ...tour, status: 'active', currentStep: 0 },
          },
        }))
      },
      advanceStep: (id) => {
        const tour = get().tours[id]
        if (!tour || tour.status !== 'active') return
        const nextStep = tour.currentStep + 1
        if (nextStep >= tour.totalSteps) {
          get().completeTour(id)
          return
        }
        set((state) => ({
          tours: {
            ...state.tours,
            [id]: {
              ...tour,
              currentStep: nextStep,
              completedSteps: [...tour.completedSteps, tour.currentStep],
            },
          },
        }))
      },
      goToStep: (id, step) => {
        const tour = get().tours[id]
        if (!tour || step < 0 || step >= tour.totalSteps) return
        set((state) => ({
          tours: { ...state.tours, [id]: { ...tour, currentStep: step } },
        }))
      },
      dismissTour: (id) => {
        const tour = get().tours[id]
        if (!tour) return
        set((state) => ({
          activeTourId: state.activeTourId === id ? null : state.activeTourId,
          tours: { ...state.tours, [id]: { ...tour, status: 'dismissed' } },
        }))
      },
      completeTour: (id) => {
        const tour = get().tours[id]
        if (!tour) return
        set((state) => ({
          activeTourId: state.activeTourId === id ? null : state.activeTourId,
          tours: {
            ...state.tours,
            [id]: {
              ...tour,
              status: 'completed',
              completedSteps: Array.from({ length: tour.totalSteps }, (_, i) => i),
            },
          },
        }))
      },
      resetTour: (id) => {
        const tour = get().tours[id]
        if (!tour) return
        set((state) => ({
          tours: {
            ...state.tours,
            [id]: { ...tour, currentStep: 0, status: 'idle', completedSteps: [] },
          },
        }))
      },
    }),
    {
      name: 'tour-progress',
      partialize: (state) => ({
        tours: Object.fromEntries(
          Object.entries(state.tours).map(([id, tour]) => [
            id,
            { status: tour.status, completedSteps: tour.completedSteps },
          ])
        ),
      }),
    }
  )
)

The partialize option is important. Without it, Zustand serializes the entire store including the active step index. That creates a race condition when the component remounts and the persisted currentStep doesn't match the actual DOM. Only persist completion status and dismissed state. Let the active step index live in memory.

When tour steps change between deploys (you added a step, removed one, reordered them), the persisted completedSteps array might reference steps that no longer exist. The registerTour guard handles this: if the totalSteps count changed, it re-registers with a fresh state.

Step 6: Wire up accessibility state

Product tours are an accessibility challenge. The tooltip needs aria-describedby, focus must move to the highlighted element, and screen readers need announcements when steps change. Zustand selectors can expose this state directly.

// src/stores/tour-a11y.ts
import { useTourStore } from './tour-store'

export function useTourAriaProps(tourId: string) {
  return useTourStore((state) => {
    const tour = state.tours[tourId]
    if (!tour || tour.status !== 'active') return null

    return {
      'aria-describedby': `tour-tooltip-${tourId}`,
      'aria-current': 'step' as const,
      role: 'status' as const,
      'aria-label': `Tour step ${tour.currentStep + 1} of ${tour.totalSteps}`,
    }
  })
}

export function useTourAnnouncement(tourId: string) {
  return useTourStore((state) => {
    const tour = state.tours[tourId]
    if (!tour || tour.status !== 'active') return ''

    return `Step ${tour.currentStep + 1} of ${tour.totalSteps}`
  })
}

Use useTourAnnouncement with an aria-live="polite" region. When the step changes, screen readers announce the new position without interrupting the user.

// Inside your tour tooltip component
const announcement = useTourAnnouncement('onboarding')

return (
  <>
    <div aria-live="polite" className="sr-only">
      {announcement}
    </div>
    {/* tooltip content */}
  </>
)

No existing tutorial connects Zustand selectors to WCAG patterns for tours. But the fit is natural: every ARIA attribute maps to a derived value from tour state.

Common issues and troubleshooting

Zustand is straightforward, but product tour state has specific edge cases around persistence hydration, re-render cascades, and server-side rendering that trip up most developers the first time. Here are the three issues we hit most often and how to fix each one.

"Tour restarts every time I refresh the page"

Check that persist middleware is configured and partialize includes the status field. If status isn't persisted, every mount resets it to idle, and your auto-start logic triggers again.

Open your browser's DevTools, go to Application > Local Storage, and look for the tour-progress key. You should see serialized tour objects with status: "completed" for finished tours.

"Tour tooltip re-renders the entire page"

You're subscribing to the full store instead of a slice. Replace useTourStore() with a focused selector like useTourStore((s) => s.tours[id]?.currentStep). Only the component reading currentStep re-renders when the step changes.

If you're returning objects from selectors, use useShallow:

import { useShallow } from 'zustand/shallow'

const { current, total } = useTourStore(
  useShallow((s) => ({
    current: s.tours[id]?.currentStep ?? 0,
    total: s.tours[id]?.totalSteps ?? 0,
  }))
)

"Hydration mismatch in Next.js"

Zustand's persist middleware reads from localStorage on mount, which doesn't exist during SSR. The store renders with default state on the server and persisted state on the client.

Use the skipHydration option and manually trigger hydration in a useEffect:

// In your layout component
import { useEffect } from 'react'
import { useTourStore } from '../stores/tour-store'

export function TourHydration() {
  useEffect(() => {
    useTourStore.persist.rehydrate()
  }, [])
  return null
}

Next steps

You now have a typed, persistent, accessible product tour state system in under 150 lines of Zustand code, covering multi-step navigation, localStorage persistence via middleware, and ARIA-aware selectors that keep screen readers informed. Here are four directions to extend it further.

  • Backend sync: Replace persist with a custom storage adapter that writes to your API instead of localStorage. Zustand's createJSONStorage accepts any getItem/setItem interface.
  • Multi-tour coordination: Register multiple tours and add a queueTour action that starts the next tour when the current one finishes.
  • Analytics: Subscribe to store changes with useTourStore.subscribe() and fire analytics events on step transitions, completions, and dismissals.
  • Conditional branching: Add a skipCondition to each step definition and modify advanceStep to skip steps whose conditions aren't met.

Tour Kit's headless architecture means none of these changes touch the rendering layer. The store evolves independently.

One honest limitation: Tour Kit is a younger project without the community size of React Joyride (603K weekly downloads) or Shepherd.js. If you need battle-tested enterprise support, evaluate those options too. But if you want full rendering control with minimal bundle cost, the Zustand + Tour Kit combination adds roughly 9KB gzipped to your app. Redux Toolkit alone costs more than that.

ConcernReact ContextZustandRedux Toolkit
Bundle size0KB (built-in)~1.2KB gzipped~11KB gzipped
Provider requiredYesNoYes
Re-render controlManual (memo + split contexts)Automatic (selectors)Automatic (selectors)
Persistence middlewareDIYBuilt-inredux-persist (3KB)
Multi-store supportAwkward (nested providers)Natural (separate stores)Single store + slices
Best for toursSimple, 1-tour appsMulti-tour apps with persistenceEnterprise apps with existing Redux

Data points sourced from bundlephobia as of April 2026.

FAQ

Does Zustand work with React 19 for product tour state?

Zustand works with React 19 out of the box. Its selector-based subscription model is compatible with React 19's concurrent features, and the ~1.2KB footprint means it adds almost nothing to your bundle. We tested Zustand v5 with React 19 and Tour Kit in a Vite project with no issues.

Can I persist tour progress across sessions with Zustand?

Zustand's built-in persist middleware saves state to localStorage automatically. Use partialize to store only completion status and dismissed tours, not the active step index. This prevents hydration conflicts when tour steps change between deploys.

How is Zustand different from React Context for tour state?

React Context re-renders every consumer when any value changes. Zustand's selector pattern lets each component subscribe to exactly the slice it needs. Your tooltip re-renders on step change while the overlay only re-renders when the tour starts or ends.

What happens to persisted tour state when I add new steps?

The registerTour function in this guide checks whether totalSteps changed since the last registration. If it did, the tour resets to a clean state. This prevents situations where completedSteps references step indices that no longer exist after a deploy. For more granular migration, implement a version field in your tour definitions.

Does adding Zustand affect my app's performance?

Zustand adds ~1.2KB gzipped to your bundle (bundlephobia). Redux Toolkit adds ~11KB, Recoil ~22KB. Its selector-based subscriptions actually improve performance over React Context by eliminating unnecessary re-renders.


JSON-LD Schema:

{
  "@context": "https://schema.org",
  "@type": "TechArticle",
  "headline": "Managing tour state with Zustand: a practical guide",
  "description": "Build product tour state management with Zustand in React. Covers step navigation, persistence, multi-tour coordination, and accessibility-driven state patterns.",
  "author": {
    "@type": "Person",
    "name": "DomiDex",
    "url": "https://tourkit.dev"
  },
  "publisher": {
    "@type": "Organization",
    "name": "Tour Kit",
    "url": "https://tourkit.dev",
    "logo": {
      "@type": "ImageObject",
      "url": "https://tourkit.dev/logo.png"
    }
  },
  "datePublished": "2026-04-07",
  "dateModified": "2026-04-07",
  "image": "https://tourkit.dev/og-images/managing-tour-state-zustand.png",
  "url": "https://tourkit.dev/blog/managing-tour-state-zustand",
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://tourkit.dev/blog/managing-tour-state-zustand"
  },
  "keywords": ["product tour zustand state", "zustand onboarding state", "tour state management react"],
  "proficiencyLevel": "Intermediate",
  "dependencies": "React 18+, TypeScript 5+, Zustand 5+",
  "programmingLanguage": {
    "@type": "ComputerLanguage",
    "name": "TypeScript"
  }
}

Internal linking suggestions:

  • Link FROM tour-progress-persistence-localstorage.mdx (related persistence topic)
  • Link FROM conditional-product-tour-user-role.mdx (state-driven conditional logic)
  • Link TO Tour Kit docs getting-started page
  • Link TO react-tour-library-benchmark-2026.mdx (bundle size comparison)

Distribution checklist:

  • Cross-post to Dev.to with canonical URL
  • Cross-post to Hashnode with canonical URL
  • Share on Reddit r/reactjs as practical tutorial
  • Answer Stack Overflow questions about "react product tour state management"

Ready to try userTourKit?

$ pnpm add @tour-kit/react