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>", ...], onResolve?: ButtonAction[], onError?: ButtonAction[], retry?: { maxAttempts: number, delayMs?: number, timeoutMs?: number } } — invokes a handler you registered on OnboardingProvider.customActions. Listed variables are read from the live ComposableScreen variable map and forwarded to the handler. See Async gates for the outcome hooks.
  • { type: "setVariable", name: "<key>", value: "<value>", label?: "<label>", valueMode?, kind?, arrayOp? } — 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.
    • valueMode: "expression" (default "literal") evaluates value as an expression over {{var}} references — arithmetic plus a small stdlib (min max abs round clamp addDays format list join count plural) — instead of storing it verbatim.
    • kind: "int" | "float" | "string" types the stored value; it decides how a later numeric comparison reads it.
    • arrayOp: "append" | "remove" | "toggle" treats the variable as the JSON-encoded string[] a multi-select CheckboxGroup writes and applies a set operation to it. kind is ignored when arrayOp is present.

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

Schema

An excerpt — only the two variants this page is about. The full eight-member union (purchase, restore, dismiss, presentPaywall, requestPermission) is documented under Button actions.

type ButtonAction =
| "continue"
| {
type: "custom";
function: string;
variables?: string[];
onResolve?: ButtonAction[];
onError?: ButtonAction[];
retry?: { maxAttempts: number; delayMs?: number; timeoutMs?: number };
}
| {
type: "setVariable";
name: string;
value: string;
label?: string;
valueMode?: "literal" | "expression";
kind?: "int" | "float" | "string";
arrayOp?: "append" | "remove" | "toggle";
};
// … plus purchase / restore / dismiss / presentPaywall / requestPermission
// 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>;
setVariable: (name: string, entry: ComposableVariableEntry) => void;
}) => 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.

setVariable(name, { value, label?, kind? }) writes back into the same variable context — the imperative counterpart to the declarative { type: "setVariable" } action. Writes update both the render store (so renderWhen / {{interpolation}} react) and the branching store, so a following "continue" branches on the new value via resolveNextStepNumber.

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, onError runs if declared, and the chain carries on to the next action — the same as purchase/restore.
Handler never settlesOnly bounded if retry.timeoutMs is declared. A timed-out attempt is a failed attempt: it retries if the budget allows, else console.error + onError. Without it the press waits forever and the CTA stays disabled.
RetryNone by default. retry: { maxAttempts } re-invokes the same handler with the same press-time variables, up to maxAttempts total attempts; delayMs pauses between them, timeoutMs bounds each one. Only set it on a handler that is safe to run twice — see Retry only what is safe to run twice.
Re-entrancyA second press on the same element while its list is still running is dropped.
Unknown handler nameconsole.error, onError runs if declared (onResolve does not — nothing resolved), and the chain carries on to the next action.
Every failure is one ruleA throw, a timeout and an unregistered name do the same thing: log, run onError, keep going. What must only run on success goes in onResolve.
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"); // runs onError; the chain carries on
},
}}

Async gates: onResolve, onError, retry and the pending state

A screen that waits on a backend or an LLM needs four things, and all four are payload — no host code beyond the handler itself.

{
"id": "generate-cta",
"type": "Button",
"props": {
"label": "Generate plan",
// 3. The CTA disables itself while its own action list runs.
"disabledWhen": { "variable": "actions.pending.generate-cta", "operator": "eq", "value": "true" },
"actions": [
{ "type": "setVariable", "name": "planError", "value": "false" },
{
"type": "custom",
"function": "generatePlan",
"variables": ["goal"],
// 1. Bounded retry. `maxAttempts` counts the FIRST attempt, so this is
// one call plus two retries, 500ms apart, and no single attempt may
// run longer than 20s.
"retry": { "maxAttempts": 3, "delayMs": 500, "timeoutMs": 20000 },
// 2. Outcome hooks, the same nested shape `purchase` and `restore` use.
"onResolve": ["continue"],
"onError": [{ "type": "setVariable", "name": "planError", "value": "true" }]
}
]
}
}
// 4. Pending and error UI, gated on variables nobody had to write by hand.
{ "id": "spinner-copy", "type": "Text",
"renderWhen": { "variable": "actions.pending.generate-cta", "operator": "eq", "value": "true" },
"props": { "content": "Generating your plan…" } },
{ "id": "error-copy", "type": "Text",
"renderWhen": { "variable": "planError", "operator": "eq", "value": "true" },
"props": { "content": "Could not reach the service. Tap to try again." } }

Put "continue" in onResolve when it must NOT run on failure. Every failure — a throw, a timed-out attempt, an unregistered handler name — runs onError and then lets the enclosing list carry on, so a "continue" placed after the custom action advances on every outcome. That is the right shape for "fire a side effect and move on" and the wrong one for a gate whose next screen reads what the handler produced. The two spellings are different intents, not a style choice:

  • "onResolve": ["continue"] — advance only when it worked.
  • [{custom}, "continue"] — advance whatever happened.

onError is not terminal, and neither is purchase's. A declared onError replaces the silence, and stops nothing. A thrown custom handler used to drop the rest of its list while every other action in the union carried on — one spelling of "on error", two behaviours, with nothing in the payload or the console to tell them apart. The failure is still console.errored whether or not a hook is declared: a hook is error UI, not a reason to lose the stack trace.

A screen must stay leaveable when the gate fails. onResolve: ["continue"] is the resolve path only — nothing in it runs when the handler fails or when the host never registered it. If that "continue" is the screen's ONLY way forward, a user whose backend is down is stuck. Give the failure path an exit: an onError that reaches a "continue" or a {"type":"dismiss"}, a visible retry beside a skip, or a "continue" trailing the action. The SDK reads the payload the same way — a custom action counts as a way off the screen only when BOTH the resolve path and the failure path reach one (completingActions.ts), the same AND rule requestPermission uses — and supplies its own CTA on a screen that has been degraded and fails that test.

Bound the attempt, or the CTA can die. With no retry.timeoutMs, a handler whose promise never settles is awaited forever: actions.pending.<elementId> stays "true", the disabledWhen above keeps the CTA greyed out, neither hook ever runs, and on a displayProgressHeader: false step there is no back chevron either. It is 1000..300000 ms, with no default, because a legitimate LLM call can take 60s+ and a silent cut-off would be the worse bug. A timeout without retries is { "maxAttempts": 1, "timeoutMs": 20000 }.

Retry only what is safe to run twice. retry re-invokes the same handler with the same press-time variables, so the validateProfile gate above with retry: { "maxAttempts": 3 } sends three POST /validate requests on a 500 — or on a res.json() that throws after the write landed. On a non-idempotent endpoint (/create-plan, a charge) that is three records or three charges for one press, and nothing in the payload, the schema or the console says so. timeoutMs widens this rather than bounding it: a timed-out attempt is an ordinary failed attempt, so a request that reached the server and lost only the answer to the clock is sent again. Retry reads, validations, and writes carrying an idempotency key; for anything else leave retry off and put the recovery in onError. (purchase has the same hazard one layer down, which is why its CTA carries disabledWhen on products.purchasing.)

The pending state is owned by the runtime. For as long as an element's action list is awaiting — across retries included — the variable map carries actions.pending ("true" while ANY element on the screen is busy) and actions.pending.<elementId> ("true" for that one). They are runtime facts and win over an author variable of the same name, exactly like resolved product variables. A per-element key is absent rather than "false" when idle, which reads as not-pending under both eq "true" and neq "true".

It applies to every element that can dispatch an action list, not just Button: the generic onPress available on any element goes through the same guard, so a pressable card is single-flight and publishes its own pending key too.

Inside a Repeat, write the pending key with the id as it appears in the templateactions.pending.row-cta. Each row scopes it to its own materialized element (the runtime's real key is suffixed, row-cta__yearly), so one row's pending state never lights up its siblings'. The screen-wide actions.pending is still screen-wide. A Repeat nested in another Repeat works the same way: the suffixes compose, and the template id is still what you write.

There is no spinner element; gate a Lottie, a ProgressIndicator or a Text with renderWhen instead.

Fan-out side effects, then continue

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

Side effects, not a gate: the trailing "continue" runs whether or not either handler succeeded, so analytics that fails offline cannot hold the user on the screen. The onError hooks here are where failure UI or a fallback would go — they are no longer needed to guarantee the exit, as they were while a throw aborted the list.

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"
]
}

Write a variable from a handler

When the value to store depends on host logic (a computation, an API result, the current value of another variable), use the setVariable setter passed to the handler instead of the static setVariable action. It writes to the same context, so the screen reacts and a following "continue" can branch on it.

{
"actions": [
{ "type": "custom", "function": "pickPlan", "variables": ["plan"] },
"continue"
]
}
customActions={{
pickPlan: async ({ variables, setVariable }) => {
const next = variables.plan?.value === "pro" ? "free" : "pro";
setVariable("plan", {
value: next,
label: next === "pro" ? "Pro" : "Free",
kind: "string",
});
},
}}

setVariable takes the full { value, label?, kind? } entry — the same shape variable-writing elements store. List any variable you need to read inside the handler in the action's variables array (reads stay filtered to that list).

note

Within a single action list, a write is not reflected in a later action's variables (they read a snapshot captured when the button was pressed). The write is visible to rendering (renderWhen / {{interpolation}}) and to a following "continue" branch. If a later action must consume a value another action just wrote, set it declaratively before the custom action or compute it inside the same handler.

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"] } }