@getrheo/react-native-expo
v2.2.0
Published
Rheo React Native SDK for Expo and Expo dev client.
Downloads
1,431
Readme
@getrheo/react-native-expo
Expo and Expo dev-client entry for the Rheo React Native SDK. Re-exports @getrheo/react-native-core and registers Expo adapters (expo-video, expo-store-review). Install one flavor: not @getrheo/react-native-bare.
React + React Native SDK for Rheo. The state machine lives in @getrheo/flow-runtime and schemas live in @getrheo/contracts; only rendering is platform-specific.
Usage
The publishable key identifies an app and environment (test or live). Pick a
channel per flow surface (channelId on <Flow /> or on
useFlow({ channelId })); that channel decides which flow version the SDK
serves. You no longer pass a flow id to useFlow.
Production
The SDK default apiBaseUrl is https://api.getrheo.io. Omit it in production unless you self-host. When using ob_pk_live_* keys, never point at localhost.
<RheoProvider
config={{
publishableKey: 'ob_pk_live_xxx',
apiBaseUrl: 'https://api.getrheo.io', // optional — matches default
userId: 'u_1',
}}
>Identity
userId(optional): Primary id for analytics and experiment bucketing. When omitted on web, the SDK generates a UUID once and stores it underlocalStorage['rheo_app_user_id']. In other runtimes (Node, SSR) it uses an in-memory singleton until you pass an explicituserId— React Native hosts should setuserId(e.g. from AsyncStorage) until a storage adapter ships.attribution(optional): When not set to{ enabled: false }, the SDK can listen forreact-native-appsflyerwhen your app installs it (not an SDK peer — integration only) and uses required peer@react-native-async-storage/async-storageafter channel resolve succeeds, but only when the workspace plan allows it (features.attributionon the resolve response — Indie plans receivefalse) and AppsFlyer is enabled for the app in App settings → Integrations (integrations.appsflyer.enabledon the resolve response). Normalized install + deep-link payloads become universalsdkAttributeskeys (acquisition.*,link.*,attribution.*). Values merge on top of hostsdkAttributesfor decision nodes. A 24h device cache (namespaced byuserId) fills gaps when a cold open delivers no MMP payload; live callbacks always override the cache. Passattribution: { storage: null }to disable persistence, orproviders: []to disable all MMP adapters while keeping the hook dormant. Add more providers later viaattribution.providers.customUserId(optional): Your backend’s user id; sent asidentity.customUserIdon every flushed event alongsideappUserId, so the dashboard can join to your systems. Optionally set once onRheoProvider;useRheoCustomUserId()exposessetCustomUserId()so CRM ids can change at runtime (updates apply to queued events at flush time).
import { Flow, RheoProvider, useRheoCustomUserId } from '@getrheo/react-native-expo';
function Screen() {
const { setCustomUserId } = useRheoCustomUserId();
// ...
return (
<Flow
channelId="ch_test_a1b2c3d4"
theme="dark"
onFlowCompleted={() => setCustomUserId('crm_known_user')}
/>
);
}
const App = () => (
<RheoProvider
config={{
publishableKey: 'ob_pk_test_xxx',
customUserId: 'crm_initial',
userId: 'u_1',
sessionId: 'sess_xyz',
appVersion: '1.4.0',
}}
>
<Screen />
</RheoProvider>
);<Flow /> is the fully managed React Native renderer — it resolves
the channel's flow, draws every layer kind (stack / text / image / button /
single-choice / multi-choice / text input / carousel), and emits the full
event taxonomy. Drop down to useFlow({ channelId }) + <LayerRenderer /> if
you need custom chrome.
Terminal payloads (onFlowCompleted / onFlowAbandoned)
Callbacks receive a versioned FlowTerminalSnapshot (schemaVersion: 1) meant for JSON.stringify → your API, CRM, or LLM prompts:
terminal:completed|abandonedoccurredAt: terminal timestamp (FlowState.completedAt)correlation: join keys only —channelId,flowId,versionId,assignmentVersion,environment,experimentId,variantId(no dashboard integration flags)subject:appUserId, optionalcustomUserId, optionalsessionIddevice:locale,platform, optionalappVersion, optionalcustomPropertiesanswers: normalized field-key → value usingstepResponseToCompletionValuefor every stored step response (except stripped auth keys), plusnullfor any capture field on a visited screen (history + current screen) that never received a response—input layers (single_choice,multiple_choice,text_input,scale_input), checkbox field keys, OS permission synthetic keys (permission:*), and app review keys (app_review:{layerId}). Same completion rules asbuildCompletionResponsesfor keys that do have responses.traits: single merged map used for decisions at terminal time (host + attribution + runtime patches)
Optional (all default false on useFlow / <Flow />):
includeManifestInTerminalPayload→manifestincludePathInTerminalPayload→path(walked screen / surface ids)includeAnswerDetailInTerminalPayload→answersDetail(raw step responses minus auth keys)
Migration from onFlowFinished: use onFlowCompleted / onFlowAbandoned; use payload.terminal if one shared handler must branch.
Explicit abandon() transitions to abandoned, enqueues flow_abandoned once, then runs onFlowAbandoned. Unmounting mid-flow still enqueues flow_abandoned when status was not already terminal (no duplicate when already abandoned).
How resolution works
useFlow({ channelId })POST /v1/sdk/resolvewith the SDK identity. Two headers are required:Authorization: Bearer <publishableKey>andX-Rheo-Channel: <channelId>.- The server looks up the channel and returns the manifest from either:
- the channel's pinned version (
assignmentKind: "direct"), or - one of the running experiment's variant arms, deterministically bucketed
by
(experimentId, appUserId)(assignmentKind: "experiment").
- the channel's pinned version (
- The response always includes
flowId,versionId,versionNumber,assignmentVersion,channelIdand (when applicable)experimentId/variantId. These are forwarded on every analytics event so funnels stay attributable across version changes. mediaMapmaps each referencedmediaAssetId(image / lottie layers) to its public CDN URL.<Flow />anduseFlowpass this through toLayerRendererautomatically; custom UIs should passmediaMapfromuseFlow(or your resolve result) intoLayerRenderer.
Caching
The resolve response carries an ETag of the form "{assignmentVersion}-{versionId}".
useFlow loads a per-channel cache from AsyncStorage and sends If-None-Match only
when a validated entry exists; 304 reuses the cached manifest. assignmentVersion
is incremented on every channel pointer change, so re-validation is essentially free.
Debug / support: listManifestResolveCacheEntries() and clearManifestResolveCache() from the package root.
Analytics reliability: events batch in memory and flush on a timer or at flow terminal. Queued events may be lost if the process is killed before flush. A disk-backed persistent queue is deferred on both RN and SwiftUI — see packages/sdks/docs/CROSS_SDK_INTEGRATION.md for cross-SDK notes (including SwiftUI equivalents).
Resolve fallback
When POST /v1/sdk/resolve fails (network outage, 4xx/5xx, or any resolve error), <Flow /> can show a host-owned escape hatch instead of blocking the user:
- Pass optional
fallback(ReactNode) — your hardcoded offline onboarding (or any UI). Full-bleed; no Rheo telemetry on that surface; the SDK does not wire retry into host fallback (remount the screen to re-resolve). - Omit
fallback— the SDK shows a default screen: "Error to load the content" and a Try again button that callsuseFlow().retry()(loading spinner while resolve runs).
useFlow exposes resolveFailed (!loading && error && !manifest), error (for logging), and retry(). Custom UIs branch on resolveFailed and render their own fallback.
Resolve still re-runs automatically on mount when channelId or identity deps change. After a successful resolve, terminal states (completed / abandoned) render nothing in <Flow /> — navigate away in onFlowCompleted / onFlowAbandoned.
Failure modes
| Status | code | Typed error | Meaning |
|--------|------------------------------|-----------------------------------|----------------------------------------------------------|
| 400 | channel_required | RheoChannelRequiredError | The X-Rheo-Channel header was missing. |
| 404 | channel_not_found | RheoChannelNotFoundError | Unknown channel id, or wrong env for this publishable key. |
| 410 | channel_archived | RheoChannelArchivedError | Channel was archived in the dashboard. Unarchive to resume. |
| 404 | channel_unassigned | — | Channel has no flow assigned yet — set one in the dashboard. |
| 404 | version_missing | — | Channel pin references a version that was deleted. |
| 404 | experiment_not_running | — | Channel pin references an experiment that's draft/stopped. |
| 400 | variant_pin_invalid | — | One of the experiment's variants has no version pinned. |
Experiments in
pending_decision(auto-paused at their end date) are still served — bucketing stays frozen until an operator promotes a winner or extends the end date.
Events & batching
Every interaction emits a typed event. Events are queued in memory and
POSTed to /v1/sdk/events either every 5 seconds, when the buffer hits
500 events, or immediately on flow_completed / flow_abandoned (so
funnel terminals are never lost to a flush window). When the queue holds
events for more than one channel, the SDK splits them into separate POSTs
(one X-Rheo-Channel header per batch, per API contract).
| Event | When it fires | Properties |
|-------------------|-------------------------------------------------------|-------------------------------------|
| flow_started | Once per resolved flow on first mount | — |
| step_viewed | When the rendered screen changes | — |
| step_completed | After a non-terminal screen submission (not skip) | — |
| step_skipped | When the flow records a skip response (e.g. skip button) | — |
| choice_selected | Per option for single-/multi-choice submissions | field_key, value |
| text_submitted | When a text input screen is submitted | field_key, value (+ classification) |
| flow_completed | When the flow reaches its terminal screen | responseCount |
| flow_abandoned | When useFlow().abandon() runs while running, or when the surface unmounts before completion (completed / abandoned) | — |
text_submitted events carry a fieldClassification so the API redacts
sensitive values before they reach ClickHouse.
In-app review (request_app_review)
Uses required peer expo-store-review: hasAction() → requestReview(), telemetry events, ~1.5s delay when shown, then advance via default next. OS declines → not_shown.
npx expo install expo-store-reviewBreaking change (built-in OS permissions)
Host apps must stop configuring onOsPermission on RheoProvider — it no longer exists. When a flow button uses request_os_permission, the SDK invokes react-native-permissions and advances the manifest using granted, denied, or blocked outcomes:
| permissionKey | Native call (summary) |
|-------------------|------------------------|
| notifications | requestNotifications (POST_NOTIFICATIONS on Android 13+) |
| camera | request (CAMERA) |
| microphone | request (RECORD_AUDIO / microphone) |
| photo_library | request (READ_MEDIA_IMAGES on Android; PHOTO_LIBRARY on iOS) |
| contacts | request (READ_CONTACTS / CONTACTS) |
| calendar | request (READ_CALENDAR; CALENDARS on iOS — read/write access tier per Apple setup) |
Missing peer, missing native setup for the specific capability, or web → the SDK resolves denied (development builds log hints where applicable).
Additional built-in handlers will ship per permissionKey without bringing back a host hook.
Native checklist (engineering, per app)
- Bare React Native: add optional peer
react-native-permissions. For each capability you ship in flows, add the matching entries to iOSsetup_permissionsand AndroidAndroidManifest.xml, plus the Info.plist usage descriptions the upstream README lists (for exampleNSPhotoLibraryUsageDescription,NSCalendarsFullAccessUsageDescriptionfor calendar). Notifications still needPOST_NOTIFICATIONSon Android 13+. - Expo: configure plugin
react-native-permissionsandios.infoPlist/android.permissionsfor every capability you ship in flows, runexpo prebuildwhen regenerating native projects,pod install, rebuild the dev client. Older Android releases may still needREAD_EXTERNAL_STORAGEfor photo-library–style prompts if yourminSdk/targetSdkrequire it. Push token registration stays app-specific beyond notification authorization. - Growth / dashboard: branches are authored only in the builder; no handler code.
Requesting authorization is separate from registering for remote push tokens; token plumbing stays in the application if you alert server-side campaigns.
Required peer dependencies (install with the SDK)
One install — all peers are required for the Expo flavor (no optional meta). @react-native-community/slider ships as a direct dependency of core.
pnpm add @getrheo/[email protected] \
react react-native \
react-native-permissions react-native-gesture-handler react-native-reanimated \
react-native-linear-gradient react-native-svg lottie-react-native \
react-native-vector-icons @react-native-async-storage/async-storage \
react-native-safe-area-context expo-store-review expo-videoIntegrations (not SDK peers): install react-native-appsflyer and/or react-native-purchases + react-native-purchases-ui only when you use attribution or RevenueCat paywall steps.
Branding fonts: use buildBrandingFontLoadMap(branding) from this package, then register faces with expo-font or linked assets.
Runnable example
Runnable sample app: getrheo/rheo-example-expo (private monorepo copy: apps/example-expo).
See the SDK developer guide for integration steps and production configuration.
