@flowpilotjs/react-native-sdk
v1.0.6
Published
FlowPilot React Native (Expo) SDK: render server-driven UI flows, onboarding, paywalls, and A/B tests in mobile apps.
Maintainers
Readme
FlowPilot React Native SDK
@flowpilotjs/react-native-sdk renders FlowPilot's server-driven UI flows inside React Native (Expo) apps. Flow definitions are authored in the FlowPilot dashboard, fetched from the FlowPilot backend, and rendered natively with animations, transitions, variables, and analytics.
This package is the JavaScript / TypeScript counterpart of the FlowPilot iOS SDK. The two SDKs share the same JSON schema and aim for pixel-parity.
Features
- Server-driven UI: ship onboarding, paywalls, and A/B tested flows without app-store releases.
- A/B testing: automatic variant assignment with exposure tracking.
- Variables system: reactive store with conditional rendering, mutations, and SDK context.
- Custom components: register your own React Native components and bind them to schema-defined inputs/outputs.
- Custom actions: extend the action chain with host-app callbacks (e.g. trigger a native paywall).
- Analytics: automatic event tracking with batching, on-disk persistence, and a hook for forwarding to your own analytics provider.
- Caching: in-memory + on-disk flow caching for offline-friendly cold starts.
- Resilience: a fail-safe resolution chain (fresh cache → live resolve with a hard timeout → stale cache → bundled default) so onboarding never hangs or strands the user, even offline.
- Offline-ready: ship a flow plus its images, icons, and fonts in-app (via the
flowpilot-exportCLI) for a fully-offline default; prefetch andisPlacementReadywarm placements ahead of time.
Requirements
- Expo SDK 51+ (managed or bare workflow)
- React Native 0.74+
- React 18+
- TypeScript 5.4+ (optional, but recommended)
The SDK is pure JS/TypeScript and ships with no native modules of its own. It only depends on the Expo modules listed under Peer dependencies below.
Installation
The SDK is published on npm as
@flowpilotjs/react-native-sdk. You still need a FlowPilot API key and app ID from the dashboard to resolve live flows.
From npm
npm install @flowpilotjs/react-native-sdk
# or
yarn add @flowpilotjs/react-native-sdkFrom a local checkout
For development, link the SDK directly from a sibling clone:
npm install ../flowpilot-EXPO-SDKPeer dependencies
These must already be installed in your app. Install any that are missing:
npx expo install expo-file-system expo-font expo-haptics expo-image expo-linking expo-secure-store react-native-reanimated react-native-safe-area-contextReanimated also requires the Babel plugin (Expo SDK 51 sets this up automatically; add it manually otherwise):
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'], // must be last
};
};Quick Start
1. Configure the SDK
Call FlowPilot.configure(...) once at app launch, typically in the root component, before any flows are presented:
import { FlowPilot } from '@flowpilotjs/react-native-sdk';
FlowPilot.configure({
apiKey: 'fp_live_your_api_key',
appId: 'your-app-id',
environment: 'production',
context: {
'user.id': 'user_123',
'user.name': 'John',
'user.is_premium': false,
},
});The API key must start with fp_ (the SDK throws otherwise). Calling configure more than once replaces the previous configuration.
2. Present a flow (imperative)
import { FlowPilot } from '@flowpilotjs/react-native-sdk';
async function startOnboarding() {
const result = await FlowPilot.presentPlacement('after_signup_success');
switch (result.outcome) {
case 'completed':
navigation.replace('Home');
break;
case 'dismissed':
// user closed the flow
break;
case 'error':
console.warn('Flow failed:', result.error);
break;
}
}presentPlacement resolves the placement, opens a full-screen modal, and resolves the returned promise once the user completes or dismisses the flow. Use this when you just need a flow shown imperatively.
3. Present a flow (declarative)
For finer control over presentation (custom safe-area handling, embedded inside a screen, etc.) build a FlowSession and render it with FlowPilotPresenter:
import { useEffect, useState } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
FlowPilot,
FlowPilotPresenter,
type FlowSession,
} from '@flowpilotjs/react-native-sdk';
export function OnboardingTrigger() {
const [session, setSession] = useState<FlowSession | null>(null);
const insets = useSafeAreaInsets();
useEffect(() => {
let cancelled = false;
FlowPilot.createSession('after_signup_success').then((s) => {
if (cancelled) return;
setSession(s);
s.start();
});
return () => {
cancelled = true;
};
}, []);
return (
<FlowPilotPresenter
session={session}
safeAreaInsets={insets}
onComplete={(result) => {
console.log('Outcome:', result.outcome);
setSession(null);
}}
/>
);
}createSession fetches the placement (using the cache if available) and returns the session unstarted, so you can attach delegates / wire callbacks before calling session.start().
Configuration Options
FlowPilot.configure({
// Required
apiKey: 'fp_live_xxx', // FlowPilot API key (must start with "fp_")
appId: 'your-app-id', // App ID from the FlowPilot dashboard
// Environment: picks the API base URL.
// Accepts 'development' | 'staging' | 'production' | { baseUrl: 'https://...' }
environment: 'production',
// SDK context: flat key/value map used by the variable system for
// conditional rendering and substitution. Keys can use dot notation
// (e.g. "user.is_premium") to match how variables reference them.
context: {
'user.id': 'user_123',
'user.is_premium': true,
},
// Caching (defaults: enabled)
cachingEnabled: true,
mediaPreloadingEnabled: true,
// Resilience: hard wall-clock deadline (seconds) for a live resolve before
// the fail-safe chain falls back to stale cache / bundled default. Default 4,
// floored at 0.5. Hitting it degrades the current caller to a fallback tier
// without cancelling the in-flight resolve (see "Offline / resilience").
resolveTimeout: 4,
// Logging: 'none' | 'error' | 'warn' | 'info' | 'debug' | 'verbose'
logLevel: 'info',
});Prefetching
Warm the cache before you need it (e.g. immediately after sign-in, so the first paywall opens with no spinner):
const outcomes = await FlowPilot.prefetch(['after_signup_success', 'paywall_main']);
for (const [key, outcome] of Object.entries(outcomes)) {
switch (outcome.state.type) {
case 'warmed':
console.log(`${key} is warm and ready`);
break;
case 'noFlow':
console.log(`${key}: backend has nothing to show`);
break;
case 'failed':
console.warn(`${key} failed to warm`, outcome.state.error);
break;
}
}prefetch never rejects: it resolves to a Record<string, PrefetchOutcome> keyed by placement, so you can tell exactly which placements are warm without a second round-trip. Input keys are de-duplicated, and the call is skipped (returning {}) when cachingEnabled is false.
Pass { warmMedia: true } to also warm images into the image cache so the flow paints instantly on arrival (bounded to the first screen by default; see prefetchMediaStrategy):
await FlowPilot.prefetch(['paywall_main'], { warmMedia: true });Prefetch on launch
Declare placements to warm automatically, once, right after configure(...). Warming runs in the background and never blocks startup; a later presentPlacement for a warmed placement hits the cache with no network round-trip. When 2+ placements are declared they are resolved in a single batch round-trip (falling back to per-placement resolves if the batch endpoint is unavailable).
FlowPilot.configure({
apiKey: 'fp_...',
appId: 'your-app-id',
prefetchOnLaunch: ['onboarding', 'paywall_main'],
prefetchMediaStrategy: 'firstScreen', // 'none' | 'firstScreen' (default) | 'allScreens'
});Warmed flows only live as long as their freshness TTL (the resolve response's
cacheTtlSeconds). Thedevelopmentenvironment uses a0TTL by default, so launch prefetch is effectively a no-op there; usestaging/productionto see the benefit.
Cached flows are keyed by an identity fingerprint (a hash of the user ID and targeting attributes), so a flow warmed for one user is never served to another after an identity change.
Readiness check
isPlacementReady(placementKey) answers whether a presentable flow is currently available for the current identity. It is routed through the same cache-populating path as prefetch, so the resolve it may perform is not wasted: a presentable flow is left warm in the cache, and a following presentPlacement hits the cache (Tier 0) instead of resolving again. The ready-then-present sequence therefore costs at most one network resolve.
if (await FlowPilot.isPlacementReady('onboarding')) {
await FlowPilot.presentPlacement('onboarding'); // no second round-trip
}It returns true when any tier can present (fresh cache, live resolve, stale cache, or bundled default) and false when the backend has nothing to show or every tier fails. It never throws.
Offline / resilience
Resolution walks a fail-safe chain so onboarding never hangs and never strands the user, even offline:
| Tier | Source | Network |
| --- | --- | --- |
| 0 | Fresh cache hit | none |
| 1 | Live resolve, bounded by resolveTimeout (default 4s) | yes |
| 2 | Stale cache (last-known-good, ignores TTL) | none |
| 3 | Bundled default flow | none |
Every analytics event carries a delivery_source (network / cache / stale_cache / bundled_default) so offline and fallback renders are distinguishable in the dashboard.
A live resolve hitting resolveTimeout degrades the current caller to a fallback tier but does not cancel the in-flight resolve, which keeps running to populate the cache for the next caller, so a slow network never hangs onboarding.
Graceful degradation. A resolved flow is decoded leniently: an unknown component type renders as a benign unknown placeholder rather than failing the whole flow, malformed nodes are dropped instead of throwing, and the schema version is gated on major only (a newer major hard-fails into the fallback tiers rather than presenting a flow this build can't parse; a newer minor/patch warns and renders best-effort). Every tier is gated by a presentability check (at least one screen node and a valid entry node) before a flow is shown, so a flow gutted by lenient decoding falls through to the next tier instead of stranding the user.
Presentation watchdog. If a presentation never produces a screen (e.g. a navigation dead-end or an empty layout that slipped past the presentability gate), a watchdog fails it with an error outcome instead of leaving the user on an infinite spinner; pair it with resolveSession / the presenter fallback (below) to show your own UI.
Bundled default flows
Ship a flow's JSON inside your app so a FlowPilot-rendered onboarding still runs with no network and no prior cache (Tier 3). Unlike iOS there is no "bundle resource by name" lookup in React Native: pass the flow JSON directly, typically require('./flows/onboarding.json') (Metro inlines it as an object). The JSON may be a full resolve-response envelope (exported from the editor, carrying media/icon base URLs and fonts) or a bare flow definition.
FlowPilot.configure({
apiKey: 'fp_...',
appId: 'your-app-id',
bundledFlows: {
onboarding: require('./flows/onboarding.json'),
},
});
// Or register one at runtime:
FlowPilot.registerBundledFlow('onboarding', require('./flows/onboarding.json'));A bundled flow JSON renders offline, but its remote images, Lucide icons, and custom fonts still hit the network and degrade to blank images / system fonts unless you also ship their bytes. Use the
flowpilot-exportCLI (below) to snapshot them into a.flowassetsbundle and register it alongside the flow.
Exporting offline assets (flowpilot-export)
flowpilot-export snapshots a placement's default flow plus its images, Lucide icons, and fonts into a self-contained .flowassets folder you drop into your app for a fully-offline Tier-3 default. It resolves the deterministic default (not an A/B variant), downloads each asset, and writes flow.json, manifest.json, the asset files, and a generated index.ts.
npx flowpilot-export --env production --api-key fp_live_... --app-id <uuid> \
--placement onboarding --out ./assets/OnboardingDefault.flowassetsThe generated index.ts exports a ready asset bundle; register it alongside the flow JSON:
import { onboardingDefault } from './assets/OnboardingDefault.flowassets';
FlowPilot.registerBundledFlow(
'onboarding',
require('./assets/OnboardingDefault.flowassets/flow.json'),
onboardingDefault,
);Pass --base-url <url> to target a custom API host, or --platform ios|android|web (default ios). Per-asset download failures are non-fatal: the command still writes a usable bundle and exits 3 so CI can flag a partial export (0 clean, 1 fatal, 2 bad args).
You can also declare the asset bundle at configure time via bundledFlowAssets, keyed by the same placement key as bundledFlows:
import { onboardingDefault } from './assets/OnboardingDefault.flowassets';
FlowPilot.configure({
apiKey: 'fp_...',
appId: 'your-app-id',
bundledFlows: { onboarding: require('./assets/OnboardingDefault.flowassets/flow.json') },
bundledFlowAssets: { onboarding: onboardingDefault },
});A BundledFlowAssets is a manifest plus a resources map. The manifest lists the flow's image/icon/font references (keyed by their resolved url, or src/name re-resolved against the flow's base URLs, and a resource path); resources maps each resource path to a require()'d Metro asset (a module number) or a local uri string. The exporter's index.ts builds this for you; the shape if you author it by hand:
const onboardingDefault: BundledFlowAssets = {
manifest: {
images: [{ url: 'https://cdn.flowpilot.io/ws/hero.png', resource: 'images/0-hero.png' }],
icons: [{ url: 'https://cdn.flowpilot.io/icons/Star.svg', resource: 'icons/Star.svg' }],
fonts: [{ family: 'Inter', weight: 700, resource: 'fonts/Inter-700.ttf' }],
},
resources: {
'images/0-hero.png': require('./images/0-hero.png'),
'icons/Star.svg': require('./icons/Star.svg'),
'fonts/Inter-700.ttf': require('./fonts/Inter-700.ttf'),
},
};Seeding is best-effort: a missing or unreadable asset is logged and skipped, degrading to a blank image / system font rather than crashing. Icons survive offline because their raw SVG bytes are seeded into the icon cache.
Host fallback
When every tier misses (no network, no cache, no bundled default), let your app render its own native onboarding instead of an error screen.
resolveSession is a non-throwing resolver: it returns a ready FlowSession, or null when nothing is presentable.
const session = await FlowPilot.resolveSession('onboarding');
return session
? <FlowPilotPresenter session={session} onComplete={handleComplete} />
: <MyNativeOnboarding />;The presenter components also accept an optional fallback (a React node or a function returning one) rendered instead of the loading spinner if a presentation fails before any screen shows:
<FlowPilotPresenter
session={session}
onComplete={handleComplete}
fallback={<MyNativeOnboarding />}
onError={(e) => console.warn('flow presentation failed', e)}
/>Analytics
The SDK automatically emits these events to the FlowPilot backend (and forwards them to any analytics callback you register):
| Event | When it fires |
| --- | --- |
| flow_start | A session starts. |
| screen_view | A new screen is displayed. |
| screen_exit | The user navigates away from a screen (carries time_on_screen_ms). |
| element_interaction | The user taps / toggles / changes a component (interaction_type field). |
| flow_complete | The flow ended via a closeFlow action or the final screen. |
| flow_exit | The flow was dismissed before completion. |
| experiment_exposure | The user was assigned to an A/B test variant. |
| conversion | You called FlowPilot.trackConversion(...) (see below). |
Events are batched (10 per request or every 30s) and persisted to disk between launches via expo-file-system, so a force-quit mid-flow won't lose data.
Forwarding to your own analytics provider
import { FlowPilot } from '@flowpilotjs/react-native-sdk';
FlowPilot.setAnalyticsCallback(({ eventName, properties }) => {
// Mirror every FlowPilot event into your own analytics pipeline
Analytics.track(eventName, properties);
});The callback fires for every event before it goes to the FlowPilot backend.
Tracking conversions
Call trackConversion when a purchase completes, typically in your IAP listener (RevenueCat / expo-in-app-purchases / etc.). The event is attributed to the most-recently-presented flow, so revenue rolls up against the paywall / onboarding flow that triggered the purchase.
// Minimal: amount + currency
FlowPilot.trackConversion(9.99, 'USD');
// With product id and custom metadata
FlowPilot.trackConversion(9.99, 'USD', 'premium_yearly', {
trial: true,
source: 'revenuecat',
});If you're holding a specific FlowSession and want to attribute to it explicitly (rather than the last one started), call session.trackConversion(...) with the same arguments.
Note: If no flow has been presented yet,
trackConversionlogs a warning and drops the event. The backend requires non-empty flow context, so always present a flow before calling.
Custom Components
Custom components let you render native UI (or any React Native subtree) inside a flow, while letting the FlowPilot editor own the actions that wire it into the rest of the flow.
Register at app startup, before any flow that uses the component is presented:
import { FlowPilot } from '@flowpilotjs/react-native-sdk';
import { MyPaywall } from './MyPaywall';
FlowPilot.registerCustomComponent('my_paywall', {
// Schema-validated by the editor
inputs: {
user_name: 'string',
is_premium: 'boolean',
show_annual: 'boolean',
},
outputs: {
purchase: {
description: 'User completed a purchase',
payload: { product_id: 'string', price: 'number' },
},
dismiss: {
description: 'User dismissed the paywall',
},
},
component: ({ props, context }) => {
const userName = (props.inputs.user_name as string) ?? '';
const isPremium = (props.inputs.is_premium as boolean) ?? false;
const showAnnual = (props.inputs.show_annual as boolean) ?? true;
return (
<MyPaywall
userName={userName}
isPremium={isPremium}
showAnnual={showAnnual}
onPurchase={(productId, price) =>
context.emit('purchase', { product_id: productId, price })
}
onDismiss={() => context.emit('dismiss')}
/>
);
},
});Inside the flow JSON the editor produces, the component is referenced by key and its inputs are either bound to a variable ({ "bind": "user.name" }) or a constant ({ "value": "dark" }):
{
"id": "paywall_1",
"type": "custom",
"props": {
"componentType": "my_paywall",
"inputs": {
"user_name": { "bind": "user.name" },
"is_premium": { "bind": "user.is_premium" },
"show_annual": { "value": true }
}
},
"interactions": [
{
"id": "on_purchase",
"event": "purchase",
"actions": [
{ "kind": "trackEvent", "eventKey": "paywall_purchase" },
{ "kind": "navigate", "targetNodeId": "success_screen" }
]
},
{
"id": "on_dismiss",
"event": "dismiss",
"actions": [{ "kind": "closeFlow" }]
}
]
}Principles:
- Custom components are renderers, not action sites. Emit events with
context.emit(eventName, payload)and let the editor decide what happens next. - Inputs are validated against the declared
inputsschema by the editor when authoring a flow. - Output payloads are schema-validated against the declared
outputsschema before being passed to action chains.
To remove a registered component (e.g. on logout), call FlowPilot.unregisterCustomComponent(key).
Custom Actions
If your flow needs to call into native code that isn't covered by the built-in action kinds, register a custom action handler:
import { FlowPilot, type ActionContext } from '@flowpilotjs/react-native-sdk';
FlowPilot.registerCustomAction('open_native_paywall', async (action, context: ActionContext) => {
await NativeBilling.presentPaywall();
});The editor then references the action by key:
{ "kind": "custom", "actionKey": "open_native_paywall" }Error Handling
FlowPilot.presentPlacement resolves to a result object: it does not throw for predictable failures (network errors, missing placement, etc.). Handle them via the outcome === 'error' branch:
const result = await FlowPilot.presentPlacement('paywall');
if (result.outcome === 'error') {
console.warn('FlowPilot failed:', result.error?.message);
}The lower-level FlowPilotError carries a typed code field ('NETWORK_ERROR', 'PLACEMENT_NOT_FOUND', 'INVALID_API_KEY', etc.) which can be matched against to drive bespoke fallback UI.
Troubleshooting
Invalid API key. Must start with "fp_".
Your apiKey is wrong or unset. The key is shown in the FlowPilot dashboard under Settings → API Keys.
FlowPilot SDK has not been configured. Call FlowPilot.configure() first.
You called presentPlacement (or createSession) before configure. Move the configure call into the root component so it runs once at startup.
Reanimated errors like [Reanimated] Couldn't determine the version of the native part
You haven't added the Babel plugin or rebuilt your app. Add react-native-reanimated/plugin to babel.config.js (as the last plugin) and run a clean rebuild (npx expo start --clear or npx expo prebuild, depending on workflow).
Flow shows a "Not supported" placeholder for a component
The flow uses a component type this SDK build doesn't render yet (for example carousel or video), so it falls through to the unknown placeholder. The built-in components, including Lottie and comparison charts, are supported. Ask whoever authored the flow to swap the unsupported component out, or upgrade to an SDK version that renders it.
Events not appearing in the dashboard
Set logLevel: 'debug' in configure to see batch flushes in the console. Common causes: app force-quit before the 30s flush timer fires (queued events replay on next launch, they're not lost); a network error after retries are exhausted (visible in the log with a Logger.warn line). On-disk persistence requires expo-file-system, so confirm it's installed as a peer dependency.
License
@flowpilotjs/react-native-sdk is released under the MIT License. See LICENSE for the full text.
