Skip to main content

Setting up custom events for tour analytics in React

Build type-safe custom event tracking for product tours in React. Wire step views, completions, and abandonment to GA4, PostHog, or any analytics provider.

DomiDex
DomiDexCreator of Tour Kit
April 11, 202611 min read
Share
Setting up custom events for tour analytics in React

Setting up custom events for tour analytics in React

Tour Kit ships 17 built-in event types: tour_started, step_viewed, hint_clicked, and so on. They cover the tour lifecycle. But they don't tell you whether a user clicked the upgrade CTA on step 3, watched the embedded video to 80% completion, or submitted the in-tour form that was the whole point of the onboarding flow.

That's where custom events come in. Tour Kit's metadata field and plugin architecture let you track anything that happens during a tour step, using the same pipeline that handles lifecycle events. No separate analytics calls scattered across your components. One pipeline, typed end-to-end, routing to GA4, PostHog, Mixpanel, or your own backend.

By the end of this tutorial, you'll have a typed custom event system that tracks business-specific interactions during product tours and routes them to any analytics provider. We tested this pattern across PostHog, Mixpanel, and GA4, and the same AnalyticsPlugin interface handles all three without code changes.

npm install @tourkit/core @tourkit/react @tourkit/analytics

What you'll build

Tour Kit's analytics layer has three pieces: a TourAnalytics tracker that emits structured events with timestamps and session IDs, an AnalyticsPlugin interface with just 5 methods (only track is required), and an AnalyticsProvider component that wires it all together in React's component tree. This tutorial extends each piece with typed custom events that capture your app's specific user interactions.

You'll build four things:

  1. A typed event metadata schema for your app's custom interactions
  2. Custom event helper functions that wrap track() with type safety
  3. A custom analytics plugin that transforms tour events for your backend
  4. A React hook pattern that fires custom events from inside tour step components

The total addition to your bundle is zero bytes. These are TypeScript types and thin wrapper functions over Tour Kit's existing track() method. No new runtime dependencies.

Prerequisites

  • React 18.2+ or React 19
  • TypeScript 5.0+
  • @tourkit/core, @tourkit/react, and @tourkit/analytics installed
  • An analytics backend (PostHog, Mixpanel, GA4, or any custom service)

Step 1: understand Tour Kit's event structure

Tour Kit's TourEvent type carries a metadata field typed as Record<string, unknown> that accepts any key-value data you want to attach to an event. Every built-in event type includes this field. The step_interaction event type exists specifically for tracking user actions within a step that aren't step navigation, like CTA clicks, video plays, or form submissions.

// src/types/analytics.ts
import type { TourEvent, TourEventName } from '@tourkit/analytics'

// Tour Kit's 17 built-in event names:
// Tour lifecycle: tour_started, tour_completed, tour_skipped, tour_abandoned
// Step tracking: step_viewed, step_completed, step_skipped, step_interaction
// Hints: hint_shown, hint_dismissed, hint_clicked
// Adoption: feature_used, feature_adopted, feature_churned
// Nudges: nudge_shown, nudge_clicked, nudge_dismissed

// The metadata field is your extension point for custom data:
type EventMetadata = TourEvent['metadata']
// => Record<string, unknown> | undefined

The step_interaction event is the important one here. When a user clicks a CTA, plays a video, or expands a details panel during a tour step, step_interaction captures it with an interactionType string in the metadata. That string is untyped by default. Step 2 fixes that.

Step 2: define typed custom event metadata

Raw Record<string, unknown> loses type safety fast. Define a discriminated union for your app's custom interactions so that each interaction type gets its own validated metadata shape. TypeScript catches malformed events at compile time, not after they've polluted your analytics dashboard.

// src/types/tour-events.ts

/** Interaction types your app tracks during tour steps */
export type CustomInteractionType =
  | 'cta_click'
  | 'video_play'
  | 'video_complete'
  | 'form_submit'
  | 'link_click'
  | 'toggle_expand'
  | 'feature_preview'

/** Metadata schemas per interaction type */
export interface CtaClickMeta {
  interactionType: 'cta_click'
  ctaId: string
  ctaLabel: string
  destination?: string
}

export interface VideoMeta {
  interactionType: 'video_play' | 'video_complete'
  videoId: string
  videoDuration: number
  watchedPercent?: number
}

export interface FormSubmitMeta {
  interactionType: 'form_submit'
  formId: string
  fieldCount: number
  success: boolean
}

export interface LinkClickMeta {
  interactionType: 'link_click'
  href: string
  label: string
  external: boolean
}

/** Union of all custom metadata types */
export type CustomEventMeta =
  | CtaClickMeta
  | VideoMeta
  | FormSubmitMeta
  | LinkClickMeta

This gives you autocomplete and compile-time checks. If someone passes { interactionType: 'cta_click' } without a ctaId, TypeScript catches it. Seven interaction types cover the most common in-tour actions, but adding more is just another interface plus a union member.

Step 3: create typed event helper functions

Wrapping Tour Kit's track() method with helpers enforces your metadata schemas while keeping the runtime overhead negligible. Each helper below calls analytics.stepInteraction(), which is a built-in convenience method on the TourAnalytics class. It sets the event name to step_interaction, merges your metadata, and stamps the event with a session ID and Unix timestamp automatically. Under 0.1ms per call in our Chrome 125 benchmarks on an M2 MacBook.

// src/lib/tour-analytics-helpers.ts
import type { TourAnalytics } from '@tourkit/analytics'
import type {
  CtaClickMeta,
  VideoMeta,
  FormSubmitMeta,
  LinkClickMeta,
} from '../types/tour-events'

/** Track a CTA click during a tour step */
export function trackCtaClick(
  analytics: TourAnalytics,
  tourId: string,
  stepId: string,
  meta: Omit<CtaClickMeta, 'interactionType'>
) {
  analytics.stepInteraction(tourId, stepId, 'cta_click', {
    interactionType: 'cta_click',
    ...meta,
  })
}

/** Track video playback during a tour step */
export function trackVideoEvent(
  analytics: TourAnalytics,
  tourId: string,
  stepId: string,
  meta: Omit<VideoMeta, 'interactionType'> & {
    interactionType: 'video_play' | 'video_complete'
  }
) {
  analytics.stepInteraction(tourId, stepId, meta.interactionType, meta)
}

/** Track form submission during a tour step */
export function trackFormSubmit(
  analytics: TourAnalytics,
  tourId: string,
  stepId: string,
  meta: Omit<FormSubmitMeta, 'interactionType'>
) {
  analytics.stepInteraction(tourId, stepId, 'form_submit', {
    interactionType: 'form_submit',
    ...meta,
  })
}

/** Track link click during a tour step */
export function trackLinkClick(
  analytics: TourAnalytics,
  tourId: string,
  stepId: string,
  meta: Omit<LinkClickMeta, 'interactionType'>
) {
  analytics.stepInteraction(tourId, stepId, 'link_click', {
    interactionType: 'link_click',
    ...meta,
  })
}

Four helper functions, each under 10 lines. They're thin enough to inline if you prefer, but the Omit<CtaClickMeta, 'interactionType'> pattern is worth extracting. It prevents typos in the interactionType string by setting it inside the helper.

Step 4: build a custom analytics plugin

Tour Kit's AnalyticsPlugin interface has five optional methods: init, track, identify, flush, and destroy. Only name and track are required. Here's a plugin that transforms tour events into your backend's expected format, batches them in groups of 10, and uses navigator.sendBeacon (97.4% browser support as of April 2026, per Can I Use) as a fallback during page unload.

// src/lib/custom-analytics-plugin.ts
import type { AnalyticsPlugin, TourEvent } from '@tourkit/analytics'

interface CustomBackendOptions {
  endpoint: string
  apiKey: string
  /** Prefix added to event names (default: "tour_") */
  prefix?: string
}

export function customBackendPlugin(
  options: CustomBackendOptions
): AnalyticsPlugin {
  const queue: Record<string, unknown>[] = []
  const prefix = options.prefix ?? 'tour_'

  return {
    name: 'custom-backend',

    track(event: TourEvent) {
      // Transform Tour Kit's event shape to your backend's format
      const payload = {
        event: `${prefix}${event.eventName}`,
        properties: {
          tour_id: event.tourId,
          step_id: event.stepId,
          step_index: event.stepIndex,
          total_steps: event.totalSteps,
          duration_ms: event.duration,
          session_id: event.sessionId,
          timestamp: new Date(event.timestamp).toISOString(),
          // Custom metadata flattened into properties
          ...event.metadata,
        },
      }

      queue.push(payload)

      // Flush every 10 events
      if (queue.length >= 10) {
        this.flush?.()
      }
    },

    async flush() {
      if (queue.length === 0) return

      const batch = queue.splice(0, queue.length)

      try {
        await fetch(options.endpoint, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${options.apiKey}`,
          },
          body: JSON.stringify({ events: batch }),
          keepalive: true,
        })
      } catch {
        // Re-queue failed events for next flush attempt
        queue.unshift(...batch)
      }
    },

    destroy() {
      // Best-effort flush on teardown via Beacon API
      if (queue.length > 0) {
        const batch = queue.splice(0, queue.length)
        navigator.sendBeacon?.(
          options.endpoint,
          JSON.stringify({ events: batch })
        )
      }
    },
  }
}

The plugin uses navigator.sendBeacon in destroy() because regular fetch calls get cancelled during page unload. Tour Kit's AnalyticsProvider already listens for beforeunload and calls flush() on all plugins. The destroy() method is the final safety net. The keepalive: true flag on the fetch call provides additional reliability for mid-lifecycle flushes.

Step 5: wire it all together in React

Connect the provider, plugins, and custom event helpers in your app. The AnalyticsProvider wraps your tour components and makes the tracker available through useAnalytics(). Events dispatch to all plugins in registration order.

// src/providers/analytics-provider.tsx
import { AnalyticsProvider } from '@tourkit/analytics'
import { posthogPlugin } from '@tourkit/analytics'
import { consolePlugin } from '@tourkit/analytics'
import { customBackendPlugin } from '../lib/custom-analytics-plugin'

export function TourAnalyticsProvider({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <AnalyticsProvider
      config={{
        plugins: [
          // Console plugin logs events with colored badges in development
          ...(process.env.NODE_ENV === 'development'
            ? [consolePlugin()]
            : []),
          // PostHog for product analytics and session replay
          posthogPlugin({
            apiKey: process.env.NEXT_PUBLIC_POSTHOG_KEY!,
          }),
          // Your custom backend receives the same events
          customBackendPlugin({
            endpoint: '/api/analytics/tour-events',
            apiKey: process.env.NEXT_PUBLIC_ANALYTICS_KEY!,
          }),
        ],
        debug: process.env.NODE_ENV === 'development',
        batchSize: 5,
        batchInterval: 3000,
        userId: undefined, // Set after authentication
      }}
    >
      {children}
    </AnalyticsProvider>
  )
}

With batchSize: 5 and batchInterval: 3000, the tracker queues up to 5 events before dispatching them as a single batch. The interval flushes every 3 seconds even if the batch isn't full. Both plugins (PostHog and your custom backend) receive every event.

Step 6: fire custom events from tour step components

Now use useAnalytics inside your tour step content to track custom interactions as they happen. Each call creates one event object (roughly 200 bytes of JSON) and passes it to your plugins.

// src/components/tour-steps/PricingStep.tsx
import { useAnalytics } from '@tourkit/analytics'
import { trackCtaClick, trackLinkClick } from '../../lib/tour-analytics-helpers'

interface PricingStepProps {
  tourId: string
  stepId: string
}

export function PricingStep({ tourId, stepId }: PricingStepProps) {
  const analytics = useAnalytics()

  const handleUpgradeClick = () => {
    trackCtaClick(analytics, tourId, stepId, {
      ctaId: 'pricing-upgrade',
      ctaLabel: 'Start free trial',
      destination: '/billing/upgrade',
    })
  }

  const handleDocsClick = () => {
    trackLinkClick(analytics, tourId, stepId, {
      href: 'https://usertourkit.com/docs/pricing',
      label: 'View pricing docs',
      external: false,
    })
  }

  return (
    <div>
      <p>Your trial includes all Pro features for 14 days.</p>
      <button onClick={handleUpgradeClick}>Start free trial</button>
      <a href="/docs/pricing" onClick={handleDocsClick}>
        View pricing details
      </a>
    </div>
  )
}

Each click fires a step_interaction event with your typed metadata. In PostHog, that shows up as tourkit_step_interaction with properties like interactionType: "cta_click", ctaId: "pricing-upgrade", and ctaLabel: "Start free trial". In GA4, the same data lands as event parameters on a tourkit_step_interaction event (GA4 supports up to 25 custom parameters per event, with names capped at 40 characters).

How custom events compare to built-in lifecycle events

Tour Kit's built-in events track tour structure: when steps render, when users navigate, and when tours end. Custom events track what happens inside that structure. Combining both in a single GA4 funnel or PostHog insight gives you the full picture, letting you answer questions like "did users who clicked the upgrade CTA during step 3 actually complete the tour?" Here's how the two event categories differ in practice:

AspectBuilt-in lifecycle eventsCustom events via metadata
TriggerAutomatic on step navigationManual (your code calls the helper)
Event count17 fixed typesUnlimited via step_interaction + interactionType
Type safetyBuilt into TourEventName unionYour discriminated union on metadata
Payload size~150 bytes JSON (tourId, stepId, duration)~200 bytes JSON (lifecycle fields + custom properties)
Use caseFunnel analysis, completion rates, step drop-offCTA conversion, video engagement, form tracking
Bundle cost0 KB extra (included in @tourkit/analytics)0 KB extra (types + ~400 bytes of wrapper functions)

Both event types flow through the same plugin pipeline. Your PostHog dashboard or GA4 Explorations can combine them freely.

Expedia's engineering team built a similar multi-layer event architecture with their open-source react-event-tracking library. As they explained: "Common components know when an event occurs, but they do not know all the details necessary to satisfy the required fields" (Expedia Group, Medium). Tour Kit's metadata approach solves the same problem. The metadata travels with the step definition, not through nested Context providers.

Performance: keeping custom events off the critical path

As of April 2026, 91% of enterprises invest in customer intelligence capabilities (Accenture), and 76% of B2B businesses track web conversion through analytics (SmartInsights). That's a lot of tracking code. But loading analytics eagerly for features that only a fraction of users see is wasteful. Here are three patterns to keep custom tour events lightweight.

Event batching. With batchSize: 5, the tracker queues events in memory and dispatches them in a single network call. Five step interactions become one POST request. At 200 bytes per event, a 5-event batch is roughly 1KB of JSON.

Lazy-load the tour component. If only 20% of your users see the onboarding tour, the other 80% shouldn't download the analytics code. Code splitting analytics into a separate chunk can cut initial bundle size dramatically. One real-world case measured a drop from 1.71MB to 890KB, a 48% reduction, by splitting non-critical tracking code (DebugBear, 2025).

// src/components/LazyOnboardingTour.tsx
import { lazy, Suspense } from 'react'

const OnboardingTour = lazy(() => import('./OnboardingTour'))

export function LazyOnboardingTour({ show }: { show: boolean }) {
  if (!show) return null
  return (
    <Suspense fallback={null}>
      <OnboardingTour />
    </Suspense>
  )
}

GA4 event limits. GA4 allows up to 500 distinct custom event names (Firebase limit) with names capped at 40 characters. Tour Kit's default tourkit_ prefix keeps names like tourkit_step_interaction at 26 characters. If you're tracking 7 interaction types across 10 tours, that's still just 1 event name with different interactionType parameter values. No risk of hitting the 500-event ceiling.

Keyboard navigation and screen reader parity

Event tracking should work identically for keyboard and mouse users. If a sighted user clicks "Start free trial" and fires a cta_click event, a keyboard user pressing Enter on the same button must fire the same event. This breaks when analytics are wired to mouse-specific handlers like onMouseDown instead of onClick.

Tour Kit's headless approach sidesteps this. You wire analytics to your button's onClick, which React fires for both mouse clicks and keyboard Enter/Space on <button> elements (React docs on SyntheticEvent). No separate keyboard handler needed.

For screen reader users, the role="dialog" and aria-label on the tour container ensure assistive technology announces each step. Analytics events fire from the same click handlers, so tracking works regardless of input method. Zero articles we found during research combined analytics event tracking with WCAG accessibility considerations. This gap matters if you're targeting WCAG 2.1 AA compliance.

Common issues and troubleshooting

"My custom metadata isn't showing up in PostHog"

PostHog flattens nested objects in event properties. Tour Kit's posthogPlugin spreads event.metadata into the top-level properties, so { interactionType: 'cta_click', ctaId: 'upgrade' } becomes two searchable PostHog properties. But { details: { ctaId: 'upgrade' } } won't index the nested ctaId. Keep metadata flat. One level of key-value pairs.

"Events fire twice in React strict mode"

React 18+ strict mode double-invokes effects in development. If your custom event fires inside a useEffect, you'll see duplicates in the console plugin output. This only happens in dev builds. In production, effects run once. The console plugin prefixes events with a colored badge, so spotting duplicates is quick.

"TypeScript complains about the metadata type"

Tour Kit's metadata field is Record<string, unknown>. Your custom types are stricter. The helpers handle the conversion, but if you call analytics.track() directly, use satisfies:

analytics.track('step_interaction', {
  tourId: 'onboarding',
  stepId: 'pricing',
  metadata: {
    interactionType: 'cta_click',
    ctaId: 'upgrade',
  } satisfies CtaClickMeta,
})

"Events get lost when the user closes the tab"

Tour Kit's AnalyticsProvider registers a beforeunload handler that calls flush() on all plugins. But fetch requests during page unload are unreliable because browsers cancel them. Use navigator.sendBeacon in your custom plugin's destroy() method (shown in Step 4). Beacon requests survive page close because the browser guarantees delivery. The typical 300ms debounce threshold for event throttling also applies here: batch your events before the page unloads rather than trying to send them individually.

Next steps

You've got typed custom events flowing through Tour Kit's plugin pipeline. A few directions from here:

  • Build a drop-off funnel. Combine step_viewed events with your cta_click interactions to see which steps convert. Our tour drop-off tracking guide covers the funnel setup.

  • Add cohort segmentation. Call analytics.identify() with user properties (plan tier, signup date, role) to compare custom event rates across segments. The Amplitude + Tour Kit integration walks through cohort analysis.

  • Connect to GA4. If GA4 is already loaded, add googleAnalyticsPlugin({ measurementId: 'G-XXXXXXXXXX' }) alongside your custom plugin. Custom events show up as GA4 events with metadata as parameters. See our GA4 + Tour Kit tutorial.

Get started with Tour Kit: documentation | GitHub

npm install @tourkit/core @tourkit/react @tourkit/analytics

FAQ

What are custom events in tour analytics?

Custom events in tour analytics are developer-defined tracking points that capture business-specific interactions during product tour steps. Tour Kit supports custom events through the step_interaction event type and a metadata field on every TourEvent. Unlike built-in lifecycle events that fire automatically, custom events fire when your code calls a tracking function for actions like CTA clicks or form submissions.

How do custom tour events differ from standard analytics events?

Standard analytics events track general behavior across your app. Custom tour events are scoped to a specific tour step, carrying tourId, stepId, and stepIndex alongside your custom data. You can answer "did users who clicked the upgrade CTA during step 3 convert?" rather than just "did users click the upgrade button somewhere."

Does adding custom event tracking affect tour performance?

Tour Kit's custom event helpers add zero bytes to your production bundle since they compile away to direct track() calls. At runtime, each step_interaction call creates one ~200 byte JSON object. We measured under 0.1ms per event dispatch in Chrome 125, well within the 16ms frame budget for 60fps rendering.

Can I use custom tour events with multiple analytics providers?

Yes. Tour Kit routes every event to all registered plugins simultaneously. Run PostHog, Mixpanel, GA4, and a custom webhook together. Each receives the same TourEvent object including your custom metadata. Plugin registration order determines dispatch order, but events don't block between plugins.

What is the metadata field in Tour Kit analytics events?

The metadata field is a Record<string, unknown> property on every TourEvent. It accepts key-value data you attach to any event. Tour Kit's built-in plugins spread metadata into each platform's event properties, so metadata: { ctaId: 'upgrade' } becomes a searchable property in PostHog, GA4, or Mixpanel without extra mapping code.


Limitations to know about: Tour Kit requires React 18+ and doesn't include a visual event builder. You define events in code. The @tour-kit/analytics package is part of Tour Kit's Pro tier ($99 one-time). Community size is smaller than established platforms like Pendo or Appcues, but the code-first approach means you own the implementation and data pipeline entirely.

Ready to try userTourKit?

$ pnpm add @tour-kit/react