Skip to main content

Custom Actions

Wire developer-defined functions to ComposableScreen Button presses. Run analytics, fire side effects, gate navigation on async work — all without writing a custom renderer.

Overview

Each Button element in a ComposableScreen carries an ordered actions array. The runtime walks the array on press, executing each entry sequentially:

  • "continue" — advances the onboarding (terminal — anything after is ignored).
  • { type: "custom", function: "<name>", variables?: ["<key>", ...] } — invokes a handler you registered on OnboardingProvider.customActions. Listed variables are read from the live ComposableScreen variable map and forwarded to the handler.
  • { type: "setVariable", name: "<key>", value: "<value>", label?: "<label>" } — writes directly to the variable map ({ value, label } shape, matching Input / RadioGroup / CheckboxGroup / DatePicker). Useful to capture which branch a user chose before a following "continue" triggers resolveNextStepNumber.

Handlers may be async — the chain awaits each Promise before proceeding.

Schema

type ButtonAction =
| "continue"
| { type: "custom"; function: string; variables?: string[] }
| { type: "setVariable"; name: string; value: string; label?: string };
// CMS payload — Button element
{
"id": "primary-cta",
"type": "Button",
"props": {
"label": "Get Started",
"variant": "filled",
"actions": [
{ "type": "custom", "function": "trackCta", "variables": ["name", "plan"] },
{ "type": "custom", "function": "syncProfile", "variables": ["name", "plan", "goals"] },
"continue"
]
}
}

Handler signature

import type {
CustomActionHandler,
CustomActions,
ComposableVariableEntry,
} from "@rocapine/react-native-onboarding";

type CustomActionHandler = (args: {
variables: Record<string, ComposableVariableEntry | undefined>;
}) => void | Promise<void>;

type CustomActions = Record<string, CustomActionHandler>;

variables is filtered to the names listed in the action's variables array. Each entry is { value: string; label?: string } — the same shape Input, RadioGroup, CheckboxGroup, and DatePicker write to the variable context. Missing keys yield undefined so the handler can detect them.

Registration

Pass customActions to the headless OnboardingProvider once at the app root.

import {
OnboardingProvider,
OnboardingStudioClient,
} from "@rocapine/react-native-onboarding";

const client = new OnboardingStudioClient(process.env.EXPO_PUBLIC_PROJECT_ID!, {
appVersion: "1.0.0",
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<OnboardingProvider
client={client}
customActions={{
trackCta: async ({ variables }) => {
await analytics.track("cta_pressed", {
name: variables.name?.value,
plan: variables.plan?.value,
});
},
syncProfile: async ({ variables }) => {
await api.post("/profile", {
name: variables.name?.value,
plan: variables.plan?.value,
goals: variables.goals?.value, // CheckboxGroup writes JSON.stringify(string[])
});
},
}}
>
{children}
</OnboardingProvider>
);
}

Execution semantics

BehaviorDetail
OrderSequential — each action awaits the prior.
AsyncReturned Promise is awaited.
"continue"Terminal — chain stops. Place "continue" last.
Thrown errorconsole.error and chain aborts (subsequent actions, including "continue", do not run).
Unknown handler nameconsole.warn and chain skips to the next action.
Empty array / no actions / no legacy actionPress is a no-op.

Variable shape by element

variables reflects whatever the variable-writing elements stored. Keep this table handy when authoring handlers:

Elementvaluelabel
InputRaw text
RadioGroupSelected item value ("monthly")Selected item label ("Monthly")
CheckboxGroupJSON.stringify(string[]) of selected valuesComma-joined display labels
DatePickerISO 8601 stringLocale-formatted date

Parse CheckboxGroup values with JSON.parse(variables.goals?.value ?? "[]") when you need the array.

Patterns

Analytics-only (no nav change)

"continue" not present → no navigation. Use this for buttons that fire side effects but stay on the screen.

{
"actions": [
{ "type": "custom", "function": "trackTooltipOpen" }
]
}

Gate navigation on a network call

{
"actions": [
{ "type": "custom", "function": "validateProfile", "variables": ["name", "email"] },
"continue"
]
}
customActions={{
validateProfile: async ({ variables }) => {
const res = await api.post("/validate", {
name: variables.name?.value,
email: variables.email?.value,
});
if (!res.ok) throw new Error("validation failed"); // aborts chain → "continue" skipped
},
}}

Fan-out side effects, then continue

{
"actions": [
{ "type": "custom", "function": "trackCta" },
{ "type": "custom", "function": "syncProfile", "variables": ["name", "plan"] },
"continue"
]
}

Branch on a button-chosen value

Pair setVariable with "continue" to set a discriminator a downstream nextStep rule can branch on. The handler reads from a ref under the hood, so the branch evaluation in the same tick sees the value just written.

{
"actions": [
{ "type": "setVariable", "name": "plan", "value": "monthly", "label": "Monthly" },
"continue"
]
}

Migrating from action: "continue"

The legacy action?: "continue" field is still accepted for back-compat. When actions is absent and action === "continue", the runtime treats it as actions: ["continue"]. Prefer emitting actions from new CMS payloads.

// Old
{ "props": { "label": "Continue", "action": "continue" } }

// New
{ "props": { "label": "Continue", "actions": ["continue"] } }