
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 zustandPrerequisites
- 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
persistwith a custom storage adapter that writes to your API instead of localStorage. Zustand'screateJSONStorageaccepts anygetItem/setIteminterface. - Multi-tour coordination: Register multiple tours and add a
queueTouraction 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
skipConditionto each step definition and modifyadvanceStepto 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.
| Concern | React Context | Zustand | Redux Toolkit |
|---|---|---|---|
| Bundle size | 0KB (built-in) | ~1.2KB gzipped | ~11KB gzipped |
| Provider required | Yes | No | Yes |
| Re-render control | Manual (memo + split contexts) | Automatic (selectors) | Automatic (selectors) |
| Persistence middleware | DIY | Built-in | redux-persist (3KB) |
| Multi-store support | Awkward (nested providers) | Natural (separate stores) | Single store + slices |
| Best for tours | Simple, 1-tour apps | Multi-tour apps with persistence | Enterprise 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"
Related articles

Amplitude + Tour Kit: measuring onboarding impact on retention
Wire Tour Kit callbacks to Amplitude track() for onboarding funnels, behavioral cohorts, and retention analysis. TypeScript examples included.
Read article
How to add a product tour to an Astro site with React islands
Add interactive product tours to an Astro site using React islands. Covers client directives, Nanostores state sharing, and Tour Kit setup.
Read article
Building conditional product tours based on user role
Build role-based product tours in React with Tour Kit. Filter steps by admin, editor, or viewer roles using the when prop and React Context.
Read article
Using CSS container queries for responsive product tours
Build product tour tooltips that adapt to their container, not the viewport. Learn CSS container queries with Tour Kit for truly responsive onboarding.
Read article