Headless external surfaces
Render a host-owned React Native component as an External Surface Node and branch on complete, back, or dismiss.
Purpose
Use an External Surface Node when a step should leave Rheo screen chrome and show your UI — for example a custom quiz sheet, native settings picker, or third-party screen Rheo does not ship. Authors place a surf_* node on the canvas (provider: "headless"); your app registers a component under the node's Host key; the SDK renders it and advances when the host calls onComplete, onBack, or onDismiss.
External Surface Nodes do not require an App Settings integration toggle. RevenueCat and Superwall paywalls use a separate Integration Node — see RevenueCat paywall and Superwall paywall.
How to
1. Author the node
- In the flow editor, add an External Surface Node from the canvas add menu (Grow+).
- Set the Host key in the inspector (defaults to the node id, e.g.
surf_custom_step). Use a stable identifier your app can register under — changing it does not rename the graph node id used for analytics and outcomes. - Wire outcomes and a required Fallback:
| Outcome | Callback / cause | Typical use |
|---|---|---|
completed | Host calls onComplete | Continue to the next step |
back | Host calls onBack | Jump to a prior screen (or any target you wire) |
dismissed | Host calls onDismiss | Soft exit / skip path |
failed | No host component registered for the host key | Safety net → usually same as Fallback |
| Fallback | Any unmapped outcome | Required before save / publish |
2. Register the host component
Pass externalSurfaces on the managed flow mount. Keys must match each node's Host key (config.hostKey when set, otherwise the surf_* node id).
React Native (Expo / bare)
import { Flow, type ExternalSurfaceHostProps } from '@getrheo/react-native-expo';
// bare: '@getrheo/react-native-bare'
const CustomStep = ({ onComplete, onBack, onDismiss }: ExternalSurfaceHostProps) => (
<>
{/* your UI */}
<Button title="Continue" onPress={onComplete} />
<Button title="Back" onPress={onBack} />
<Button title="Skip" onPress={onDismiss} />
</>
);
<Flow
channelId="ch_live_xyz"
externalSurfaces={{
surf_custom_step: CustomStep,
}}
/>;With headless useFlow, read pendingExternalSurface and call reportExternalSurfaceOutcome(nodeId, 'completed' | 'back' | 'dismissed'), or render the same component yourself using those callbacks.
3. Publish and resolve
Publish the flow and assign it to a channel as usual. When the graph lands on the External Surface Node, Rheo clears the Rheo screen, emits surface_presented, renders your component, and waits for a callback. Each callback emits surface_outcome and jumps via the wired target (or Fallback).
Host props
| Prop | Type | Role |
|---|---|---|
surfaceId | string | Host key (config.hostKey or node id) |
node | external surface node | Full manifest node (config, outcomes, fallback) |
onComplete | () => void | Outcome completed |
onBack | () => void | Outcome back |
onDismiss | () => void | Outcome dismissed |
Call exactly one of the callbacks when the host UI is finished. Calling more than once after the flow has already advanced is ignored by the state machine.
Manifest shape
{
"id": "surf_custom_step",
"name": "Custom host UI",
"config": { "provider": "headless", "hostKey": "onboardingQuiz" },
"outcomes": {
"completed": "scr_next",
"back": "scr_welcome",
"dismissed": "scr_skip",
"failed": "scr_skip"
},
"fallback": "scr_skip"
}Omit hostKey to use the node id as the registry key.
Analytics
| Event | When |
|---|---|
surface_presented | Flow lands on the headless node (provider: "headless") |
surface_outcome | Host callback or missing-component failure (completed / back / dismissed / failed) |
See Event catalog.
Guardrails
- Registry is required for success. If the flow hits an External Surface Node and
externalSurfaces[hostKey]is missing, the SDK resolvesfailedand follows that branch / Fallback. - Not a modal paywall presenter. Unlike RevenueCat / Superwall Integration Nodes, headless UI is rendered in place by
Flow(no promise-basedexternalSurfacePresenter). - Keep partner paywalls separate. Do not use an External Surface Node to wrap RevenueCat or Superwall paywalls when you need purchase commerce events — use an Integration Node with
provider: "revenuecat"orprovider: "superwall"instead. - Host key uniqueness. Each headless node's resolved host key (
hostKeyor node id) must be unique in the flow. - Host key vs node id. Changing the Host key in the inspector updates the registry lookup only; outcomes and analytics still use the stable
surf_*node id.
Troubleshooting
- Blank flash then Fallback: no component registered for that host key — check spelling and that you pass
externalSurfacesinto the sameFlowinstance that runs the channel. - Callback does nothing: the surface may already have advanced; ensure you call the callback only once from user action.
- Publish blocked on Fallback: connect the Fallback handle in the inspector or canvas.