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.1Bare 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.