Skip to main content
The 14 built-in components cover most flows, but sometimes you need native UI a flow cannot express, like a StoreKit paywall, a map, or a camera preview. Custom components let you render any React Native subtree inside a flow while the editor owns how it wires into the rest of the flow. Custom actions let a flow call into your native code.

Custom components

A custom component is a renderer, not an action site. It receives typed inputs from the flow, renders your React Native UI, and emits named events. The editor decides what each event does (navigate, set a variable, close the flow, and so on).

Register at startup

Register before any flow that uses the component is presented, normally right after configure(...).
import { FlowPilot } from '@flowpilotjs/react-native-sdk';
import { MyPaywall } from './MyPaywall';

FlowPilot.registerCustomComponent('my_paywall', {
  // Declared input schema (validated by the editor when authoring a flow)
  inputs: {
    user_name: 'string',
    is_premium: 'boolean',
    show_annual: 'boolean',
  },
  // Declared output events the component can emit
  outputs: {
    purchase: {
      description: 'User completed a purchase',
      payload: { product_id: 'string', price: 'number' },
    },
    dismiss: {
      description: 'User dismissed the paywall',
    },
  },
  // Your React component
  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')}
      />
    );
  },
});

Registration shape

FlowPilot.registerCustomComponent(key: string, definition: {
  version?: number;                                  // default 1
  inputs?: Record<string, 'string' | 'number' | 'boolean' | 'list'>;
  outputs?: Record<string, {
    description?: string;
    payload?: Record<string, 'string' | 'number' | 'boolean' | 'list'>;
  }>;
  component: React.ComponentType<CustomComponentRenderProps>;
});

What your component receives

Your component is rendered with CustomComponentRenderProps:
interface CustomComponentRenderProps {
  props: {
    inputs: Record<string, VariableValue>; // resolved input values from the flow
  };
  context: {
    containerSize: { width: number; height: number };
    containerConstraints: {
      minWidth?: number;
      maxWidth?: number;
      minHeight?: number;
      maxHeight?: number;
      supportsIntrinsicSize: boolean;
    };
    emit(eventName: string, payload?: Record<string, unknown>): void;
  };
}
  • props.inputs holds the resolved input values. Each is a VariableValue (string, number, boolean, or array), so cast to the type you declared.
  • context.emit(eventName, payload?) fires one of your declared output events. The editor’s interaction for that event runs its action chain. Output payloads are validated against the outputs schema before reaching the chain.
  • context.containerSize / containerConstraints describe the space the flow gave your component, for laying out responsively.

How the flow references it

The editor produces flow JSON that references the component by key and binds each input to a variable ({ "bind": "user.name" }) or a constant ({ "value": true }), and wires each emitted event to an action chain:
{
  "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" }]
    }
  ]
}

Unregister

To remove a registered component (for example on logout), call:
FlowPilot.unregisterCustomComponent('my_paywall');         // version 1
FlowPilot.unregisterCustomComponent('my_paywall', 2);      // a specific version

Principles

  1. Custom components are renderers, not action sites. Emit events with context.emit(...) and let the editor decide what happens next.
  2. Inputs are validated against the declared inputs schema by the editor when authoring a flow.
  3. Output payloads are schema-validated against the declared outputs schema before being passed to action chains.

Custom actions

If a flow needs to call native code that the built-in action kinds do not cover (open a native paywall, request a permission, start a purchase), register a custom action handler. The editor then references it by key with a custom action.
import { FlowPilot, type ActionContext } from '@flowpilotjs/react-native-sdk';

FlowPilot.registerCustomAction('open_native_paywall', async (params, context: ActionContext) => {
  await NativeBilling.presentPaywall();
  // Optionally read/write flow variables via context.variableStore
});
The handler signature:
type CustomActionHandler = (
  params: Record<string, unknown> | undefined,
  context: ActionContext,
) => Promise<void>;
params carries any params object the editor attached to the action. context gives you the flow’s variableStore, navigationController, the current flowId / screenId, and isFlowClosed. The editor references the action like this:
{ "kind": "custom", "actionKey": "open_native_paywall", "params": { "plan": "annual" } }
Remove a handler with FlowPilot.unregisterCustomAction('open_native_paywall').
Action chains run with timeout protection (about 5s per chain, 2s per async action). A custom action that hangs is abandoned so it cannot freeze the flow, and an unregistered custom action is skipped with a warning, not an error. Keep handlers fast, or kick off long work and return.

Common mistakes

  • Registering after presenting. Register custom components and actions before the flow that uses them is presented, or the component shows a placeholder and the action is skipped.
  • Key mismatch. The key you register must match the componentType / actionKey in the flow exactly (case-sensitive).
  • Version mismatch. Components match on key + version. If the flow uses version 2, register version 2.
  • Treating a custom component as an action site. Do not call navigation directly from inside the component. Emit an event and let the editor’s interaction handle it, so flow logic stays in the editor.
  • Long-running custom actions. A handler that exceeds the per-action timeout is abandoned. Start the work and return, or report progress through a custom component instead.

Troubleshooting

  • Component renders a blank placeholder. It was not registered, registered after presenting, or the key/version does not match the flow. Register the exact key and version at startup. Enable logLevel: 'debug' to see the lookup miss.
  • An emitted event does nothing. The editor has no interaction wired for that event name, or the event name does not match the declared output. Confirm the outputs key matches what you emit(...).
  • A custom action never runs. The handler key does not match the flow’s actionKey, or it was registered too late. Register before presenting, and check the warning in debug logs.