Documentation

Prepare Journeys in React Native

Open Expo and React Native Journeys without a loading-screen delay.

JourneyHost prepares a Journey before the person opens it. It downloads the published configuration while one persistent renderer shell starts in parallel, then reveals that same WebView when you call present.

Before you start

Install @founderhq/journeys-react-native 0.8.1 or later. Preparation arrived in 0.7.0; JourneyView keeps its existing API.

Install

npx expo install react-native-webview react-native-safe-area-context expo-haptics
npm install @founderhq/journeys-react-native@^0.8.1

Bare React Native apps need react-native-webview and react-native-safe-area-context and import from the package root. On Android, also allow Journey haptic feedback in android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.VIBRATE" />

Expo apps import from /expo to get Expo haptics by default. Expo normally adds the Android vibration permission through native manifest merging.

Mount one persistent host

Mount JourneyHost once beside your navigator. Keep it in the tree while the app is running. It is a normal React Native view, not a modal, so it inherits the size of its parent and does not create a second window.

import {
  JourneyHost,
  type JourneyHostRef,
} from "@founderhq/journeys-react-native/expo";
import { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

const onboarding = {
  journeyId: "journey_123",
  identity: { externalId: "usr_1042" },
  storageKey: "onboarding",
};

export function App() {
  const journeys = useRef<JourneyHostRef>(null);
  const insets = useSafeAreaInsets();

  async function prepareOnboarding() {
    await journeys.current?.prepare(onboarding);
  }

  async function openOnboarding() {
    // This also prepares when prepareOnboarding has not run yet.
    await journeys.current?.present(onboarding);
  }

  return (
    <View style={{ flex: 1 }}>
      <AppNavigator
        onOnboardingLikely={prepareOnboarding}
        onOpenOnboarding={openOnboarding}
      />

      <JourneyHost
        ref={journeys}
        apiKey={process.env.EXPO_PUBLIC_FOUNDERHQ_KEY!}
        onEvent={(event) => {
          if (event.type === "complete") {
            journeys.current?.dismiss();
          }
        }}
      >
        <Pressable
          accessibilityLabel="Close Journey"
          accessibilityRole="button"
          onPress={() => journeys.current?.dismiss()}
          style={{ position: "absolute", right: 12, top: insets.top + 8 }}
        >
          <Text>Close</Text>
        </Pressable>
      </JourneyHost>
    </View>
  );
}

The parent must have a real size, normally flex: 1. The host fills that parent. Your app continues to own status-bar, navigation-bar, and safe-area policy. Pass native controls as JourneyHost children when they must appear above the Journey, and inset those controls with react-native-safe-area-context.

Do not mount a second JourneyHost or a separate JourneyView at the same time. One persistent host owns one renderer WebView.

Prepare at the useful moment

Call prepare when intent becomes likely: after sign-in, when the preceding screen opens, or when the person enters the part of the app that can launch the Journey. Preparation is safe to repeat. Concurrent calls for the same inputs share one request.

const prepared = await journeys.current?.prepare({
  journeyId: "journey_123",
  identity: {
    externalId: account.id,
    email: account.email,
  },
  initialAnswers: { company_size: account.companySize },
  capture: {
    context: { source: "mobile-onboarding" },
  },
});

console.log(prepared?.revisionId, prepared?.refreshAt);

prepare resolves when the Journey is ready. present resolves when it is visible; completion and purchase intents arrive through onEvent, so there is no presentation promise waiting for the Journey to finish.

After dismiss, the host keeps the downloaded configuration and prepares a fresh client session in the same WebView. Answers and navigation from the last presentation are not reused unless your own storageKey policy restores them.

Freshness and publication changes

A successful preparation is fresh for at least 10 minutes. FounderHQ returns a refresh interval that the SDK clamps to 10–30 minutes. The host schedules a refresh while foregrounded, including during an active Journey, and checks again when the app resumes.

A ready preparation opens immediately through 30 minutes after its last successful fetch. When refresh is due, presentation happens first and the refresh runs asynchronously. Content older than 30 minutes requires a successful fetch before a new presentation. The displayed configuration and answers remain stable during an active Journey, even beyond that window; updated content is staged for the next presentation.

Only validated configuration or an authenticated unchanged response matching cached JSON renews freshness. Failed requests do not renew it or create an automatic retry loop. Present, resume, and explicit retry can try again after backoff. Requests coalesce and honor Retry-After. Loading has a cumulative 15-second foreground deadline; time in the background does not consume it.

A definitive 401, 403, or confirmed Journey 404 discards preparations and stops active interaction. Explicit prepare, present, or Try again can freshly authorize the same inputs after access is restored. Cached content is never reused after a denial. Downloaded content may remain displayed offline until the device receives a denial. Restarting the app requires fresh authorization for new presentations while pending answers retain their original attribution.

Input and account changes

The host invalidates preparation when any of these values change:

  • API key, FounderHQ base URL, or renderer URL
  • Journey ID or local test configuration
  • identity, initial answers, or initial options
  • capture settings or capture callbacks
  • storage key or theme

Changing the host API key or URL disposes the old credential scope automatically. Call dispose when the signed-in account changes, even when two accounts happen to use the same key and Journey ID.

await auth.signOut();
journeys.current?.dispose();

dispose drops the cached preparation and renderer content. The SDK also disposes an unused renderer on an OS memory warning. A later prepare or present(input) starts a new renderer shell.

Use 0.7.1 or later for immediate dispose(); present(input) or dispose(); prepare(input) calls. It recreates the renderer shell even when React batches disposal and the next request into one update.

Capture after dismissal or restart

Capture batches can finish after a Journey is dismissed or a new session is prepared. Every batch keeps the session, revision, visitor, identity, and context recorded when its events occurred. The native host forwards that body unchanged; it never relabels delayed events with the current presentation.

Failed batches remain eligible for replay across a renderer restart. A custom capture transport receives the same immutable request body:

await journeys.current?.prepare({
  journeyId: "journey_123",
  capture: {
    transport: async ({ url, method, body }) => {
      const response = await yourNetworkClient.request({ url, method, body });
      return response.ok;
    },
  },
});

Retry and customize the host

The default loading state is an accessible theme-colored ring with no status copy. It respects the device's reduced-motion setting. The default error state uses a short generic message and a retry button.

Use indicatorColor to match your app. Use errorComponent when you need your own error surface; its retry callback follows the same 15-second deadline and freshness rules.

<JourneyHost
  ref={journeys}
  apiKey="fhq_pk_XXXX"
  indicatorColor="#7c3aed"
  errorComponent={(error, retry) => (
    <YourErrorCard
      canRetry={error.recoverable}
      onRetry={retry}
    />
  )}
  onStatusChange={(status) => {
    // idle, preparing, prepared, presenting, visible, error, or blocked
  }}
/>

You can also provide onOpenURL, onDiscountCodeApply, haptics, and onError. The ref retains goNext, goBack, goToStep, setAnswer, and flushCapture for native controls.

Keep the direct view when preparation is unnecessary

JourneyView still works for a screen that naturally mounts before the person expects content. It validates access, fetches the published configuration, and renders directly:

import { JourneyView } from "@founderhq/journeys-react-native/expo";

<JourneyView
  apiKey="fhq_pk_XXXX"
  journeyId="journey_123"
  identity={{ externalId: "usr_1042" }}
  style={{ flex: 1 }}
/>

The persistent host also falls back safely when it encounters a renderer from before preparation support. It fetches and authorizes the configuration ahead of time, but does not initialize a hidden Journey. present initializes it as visible, which avoids hidden analytics while preserving compatibility.

AI agent or LLM? Read this page as markdown

On this page