Skip to main content

React 19 concurrent mode and product tours: what you need to know

How React 19 concurrent rendering affects product tours. Covers useTransition, useDeferredValue, and Suspense for lazy-loaded tour steps.

DomiDex
DomiDexCreator of Tour Kit
April 8, 20269 min read
Share
React 19 concurrent mode and product tours: what you need to know

React 19 concurrent mode and product tours: what you need to know

React 19 made concurrent rendering the default. Not opt-in, not experimental, not behind a flag. Every React 19 app gets interruptible rendering, priority-based scheduling, and ~5ms yield windows out of the box. But what does that actually mean for product tour libraries? The tooltip overlays, element highlights, and step-by-step flows that sit on top of your UI?

Most articles about concurrent mode use the same examples: search boxes, filterable lists, tab switching. Nobody talks about overlay UIs. Product tours are a perfect case study because they combine the three things concurrent rendering handles best: async content loading, expensive position calculations, and UI updates that must stay responsive during user interaction.

npm install @tourkit/core @tourkit/react

We built Tour Kit on React 19 from day one, so we've had time to test how these concurrent features behave with real onboarding flows. Here's what we found, and what matters for your tour implementation.

What changed between React 18 and React 19 concurrent rendering?

React 18 introduced concurrent features as opt-in APIs. You had to wrap updates in startTransition or use useDeferredValue explicitly to get any benefit. React 19 flips that model: concurrent rendering is the default scheduling behavior for all components. The scheduler slices rendering work into ~5ms chunks and yields to the browser between them, preventing long-running renders from blocking input events or animation frames.

For product tours, this shift matters more than it sounds. A tour overlay triggers three operations on every step change: recalculate the highlight position, render the new tooltip content, and update the backdrop cutout.

In React 18, if that work exceeded 16ms (one frame at 60fps), the browser would drop frames. Users would see a flash or stutter between steps. React 19's scheduler breaks that work into interruptible chunks, so even a complex step transition with media content stays visually smooth.

FeatureReact 18React 19
Concurrent renderingOpt-in via startTransitionDefault for all apps
startTransition callbackSynchronous onlyAsync callbacks supported
useDeferredValueNo initial value parameterSupports initialValue
SuspenseLazy loading coordinatorFull async rendering coordinator
use() hookNot availableReads promises and context directly
React CompilerNot availableAuto-memoizes, reduces manual useMemo/useCallback

Sources: React v19 blog post, React 19 upgrade guide

How useTransition improves step navigation

useTransition separates urgent updates (the user clicked "Next") from non-urgent updates (rendering the next step's content). Click response must be instant. But the incoming step might include a video embed, a code example, or content fetched from an API.

Without useTransition, React treats all of that as one synchronous update, so the button feels sluggish until everything finishes rendering.

In React 19, startTransition accepts async callbacks. That's new. React 18 required synchronous callbacks, which meant you couldn't await a fetch inside a transition. Now you can load step content, wait for it, and render it while keeping the navigation controls responsive.

// src/components/TourStepNavigator.tsx
import { useTransition } from 'react';
import { useTour } from '@tourkit/react';

function TourStepNavigator() {
  const [isPending, startTransition] = useTransition();
  const { goToStep, currentStep, steps } = useTour();

  function handleNext() {
    startTransition(async () => {
      // Load the next step's content (async in React 19)
      const nextStep = steps[currentStep.index + 1];
      if (nextStep?.loadContent) {
        await nextStep.loadContent();
      }
      goToStep(currentStep.index + 1);
    });
  }

  return (
    <button onClick={handleNext} disabled={isPending}>
      {isPending ? 'Loading...' : 'Next'}
    </button>
  );
}

The isPending flag gives you a free loading indicator. No useState for loading state, no useEffect cleanup. React manages it.

We measured this in a 12-step onboarding tour with embedded media. Without useTransition, clicking "Next" on a step with a video embed showed a 180ms input delay on a mid-range Android device (4x CPU throttle). With useTransition, input delay dropped to under 16ms. The transition still took the same total time, but the button responded immediately. That perceived performance gap is what users actually feel.

Why useDeferredValue matters for highlight positioning

Product tours constantly recalculate element positions. Scroll, resize, layout shift: every change means the highlight overlay and tooltip need to reposition. That means calling getBoundingClientRect(), computing offsets, and updating CSS transforms, potentially on every frame.

useDeferredValue tells React: "use the stale value until you have idle time to compute the new one." Unlike debounce() or throttle(), there's no fixed delay. On a fast laptop, the deferred value updates almost immediately. On a budget phone, React holds the stale value longer to keep the UI responsive.

React 19 adds initialValue support, which is particularly useful for tour highlights:

// src/hooks/useDeferredHighlight.tsx
import { useDeferredValue, useMemo } from 'react';

function useHighlightPosition(targetRef: React.RefObject<HTMLElement>) {
  const rect = useElementRect(targetRef); // updates on scroll/resize

  // Defer expensive position calculations
  // initialValue prevents layout flash on first render
  const deferredRect = useDeferredValue(rect, {
    x: 0, y: 0, width: 0, height: 0
  });

  return useMemo(() => ({
    top: deferredRect.y - 8,
    left: deferredRect.x - 8,
    width: deferredRect.width + 16,
    height: deferredRect.height + 16,
  }), [deferredRect]);
}

The highlight follows the target element, but during rapid scrolling, React prioritizes keeping the page scroll smooth over updating the highlight position. The user sees the highlight "catch up" after scrolling stops. That's the right tradeoff. Nobody examines a tour highlight while actively scrolling.

Josh W. Comeau's useDeferredValue explainer covers the mechanics in detail. Short version: it's useMemo that React can interrupt.

Lazy-loading tour steps with Suspense and use()

A 20-step onboarding tour shouldn't load all 20 steps upfront. React 19 promotes Suspense from a "lazy-loading helper" to a full async rendering coordinator. Combined with the new use() hook, you can load tour step content on demand without the useEffect + useState boilerplate that cluttered React 18 code.

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

// Code-split each step component
const steps = {
  welcome: lazy(() => import('./steps/WelcomeStep')),
  dashboard: lazy(() => import('./steps/DashboardStep')),
  settings: lazy(() => import('./steps/SettingsStep')),
};

function TourStepRenderer({ stepId }: { stepId: keyof typeof steps }) {
  const StepComponent = steps[stepId];

  return (
    <Suspense fallback={<StepSkeleton />}>
      <StepComponent />
    </Suspense>
  );
}

When the user reaches step 3, only step 3's component loads. Steps 1 and 2 can be garbage collected. Step 4 hasn't been fetched yet. For a content-heavy onboarding flow with images, videos, and interactive demos, this keeps the initial bundle impact near zero.

The use() hook takes this further. If your tour steps pull configuration from an API (common in SaaS apps where onboarding varies by plan tier), you can read the promise directly:

// src/components/DynamicStep.tsx
import { use, Suspense } from 'react';

const tourConfig = fetch('/api/tour-config').then(r => r.json());

function DynamicTourStep() {
  const config = use(tourConfig);

  return (
    <div>
      <h3>{config.steps[0].title}</h3>
      <p>{config.steps[0].description}</p>
    </div>
  );
}

// Wrap in Suspense at the tour level
function Tour() {
  return (
    <Suspense fallback={<TourSkeleton />}>
      <DynamicTourStep />
    </Suspense>
  );
}

No useEffect. No loading state. No cleanup function. React handles the pending state through Suspense boundaries, and Error Boundaries catch failures. According to web.dev's guide on code splitting with Suspense, this pattern reduces First Contentful Paint by keeping initial bundles lean.

Accessible step transitions with concurrent features

Concurrent rendering and accessibility complement each other for tour UIs. At React Advanced 2025, Aurora Scharff demonstrated how useTransition combined with ARIAKit eliminates the flickering pending states that plague accessible UI components (InfoQ, December 2025).

Product tours face a specific accessibility challenge: when a step transition takes time (loading content, scrolling to a target element), screen readers need to know something is happening. useTransition's isPending flag maps directly to ARIA live regions:

// src/components/AccessibleTourNav.tsx
import { useTransition } from 'react';

function AccessibleTourNav() {
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <div aria-live="polite" className="sr-only">
        {isPending ? 'Loading next tour step' : ''}
      </div>
      <button
        onClick={() => startTransition(() => goToNextStep())}
        aria-busy={isPending}
      >
        Next step
      </button>
    </>
  );
}

The aria-busy attribute tells assistive technology that the button's associated content is updating. The aria-live="polite" region announces loading state without interrupting the user. Both are native HTML attributes that cost zero bytes. They just need the right timing signal, which isPending provides for free.

Tour Kit ships with WCAG 2.1 AA compliance built into every component, including focus management during concurrent transitions. When a step change is pending, focus stays on the current step's controls rather than jumping to a half-rendered next step.

Common mistakes to avoid

Wrapping every update in startTransition. Not all tour updates need transitions. Direct user interactions (clicking a tooltip, dismissing a step, typing in a survey field) should be urgent updates. Only defer non-urgent operations: content loading, position recalculation, analytics tracking.

Forgetting Suspense boundaries. Lazy-loaded tour steps without a Suspense boundary crash with a clear error, but the fix isn't always obvious in nested component trees. Place your Suspense boundary at the tour container level, not around individual steps.

Over-deferring position updates. Using useDeferredValue for tooltip position during a step change makes the tooltip appear at the old position briefly. For step-change positioning (not scroll), calculate the new position as an urgent update. Reserve useDeferredValue for continuous repositioning during scroll or resize.

Ignoring the React Compiler. React 19's compiler auto-memoizes components and values. If you're still wrapping tour step content in useMemo and callbacks in useCallback, you're adding complexity the compiler handles. Remove manual memoization and test. You'll likely see identical performance with less code.

What about libraries that don't support React 19?

As of April 2026, most popular React tour libraries work with React 19. They just don't take advantage of its concurrent features. React Joyride (37KB gzipped, 603K weekly npm downloads) lists React 16.8+ as its peer dependency. It renders fine in React 19, but its architecture predates concurrent rendering. Tooltip positioning happens synchronously. Step transitions block the main thread. There's no Suspense integration for lazy-loaded steps.

Shepherd.js requires AGPL licensing for commercial use and hasn't documented concurrent mode support. Driver.js is vanilla JS with a React wrapper, so concurrent rendering doesn't affect its internal scheduling.

Tour Kit's core ships at under 8KB gzipped and was built for React 19's rendering model from scratch. Every hook uses concurrent-safe patterns, with no stale closure bugs from interrupted renders and no torn reads from concurrent updates.

We should note that Tour Kit is younger than these established libraries, with a smaller community and less battle-testing at enterprise scale. But for teams already on React 19, the concurrent rendering integration is a genuine architectural advantage.

Try Tour Kit in a live sandbox to see concurrent step transitions in action.

FAQ

Does React 19 concurrent mode break existing product tours?

React 19 concurrent rendering is backward compatible. Libraries built for React 16+ continue to work without errors. But they miss performance benefits: step transitions still block the main thread, and lazy-loading tour content still requires manual useEffect boilerplate. The "break" is opportunity cost, not runtime errors.

How does useTransition affect tour step load times?

useTransition doesn't make tour steps load faster. Total time stays the same. What changes is perceived performance: the button responds immediately while content loads in the background. We measured input delay dropping from 180ms to under 16ms on steps with embedded media using React 19's async startTransition.

Should I use Jotai or Zustand for tour state with concurrent mode?

Jotai has native concurrent mode and Suspense support (comparison docs), making it stronger for tour state that needs code splitting. Zustand works as module state but doesn't integrate with Suspense. For simple tours under 10 steps, either works. For complex flows with dynamic content, pick Jotai.

Is Tour Kit compatible with React 18 apps?

Tour Kit requires React 18 or later. It works in React 18, but you'll need to opt into concurrent features manually with startTransition and Suspense. React 19 apps get concurrent rendering automatically. No mobile SDK or React Native support (browser-only, React 18+). The upgrade guide covers the migration path.

What is the React 19.2 Activity component and how does it help tours?

React 19.2 introduces the Activity component (previously called Offscreen) for pre-rendering content that isn't visible yet. For product tours, this means pre-rendering the next step while the user reads the current one. Click "Next" and it appears instantly. As of April 2026, Activity is in early release and the API may change.


Ready to try userTourKit?

$ pnpm add @tour-kit/react