Docs navigation
Custom step + offer types
The SDK accepts any string as a step or offer type. Anything beyond survey, offer, feedback, confirm, and success is a custom type, routed to the component you register on customComponents. The SDK navigates through it the same way it does a built-in and records its result on the session.
Before you begin
You have a basic cancel flow running. This guide adds custom step and offer types to it.
Custom step
Add the step to your steps array with any type string, then register a component for that type on customComponents.
<CancelFlow
steps={[
{ type: 'survey', reasons: [/* ... */] },
{
type: 'nps',
title: 'How likely are you to recommend us?',
description: 'Pick the face that matches your honest take.',
data: { scale: 10 },
},
{ type: 'feedback' },
{ type: 'confirm' },
]}
customComponents={{ nps: NpsStep }}
handleCancel={handleCancel}
/>The data field carries arbitrary config to your component — the NPS scale, the question text, a list of options. The SDK passes it through verbatim and never reads it.
The component receives a fixed prop shape:
| Prop | Type | What it is |
|---|---|---|
step | { type, title?, description?, data? } | The step config you passed in. |
customer | DirectCustomer | null | The customer passed to <CancelFlow>, or null if none. |
subscriptions | DirectSubscription[] | The subscriptions passed to <CancelFlow>, or empty. |
onNext | (result?: object) => void | Advance. The result is captured on the session. |
onBack | () => void | Go back. No-op on the first step. |
A complete component:
import { useState } from 'react'
import type { CustomStepProps } from '@churnkey/react'
export function NpsStep({ step, onNext, onBack }: CustomStepProps) {
const [score, setScore] = useState<number | null>(null)
const scale = step.data?.scale ?? 10
return (
<div>
<h2>{step.title}</h2>
{step.description && <p>{step.description}</p>}
<div role="radiogroup">
{Array.from({ length: scale }, (_, i) => i + 1).map((n) => (
<button
key={n}
type="button"
role="radio"
aria-checked={score === n}
onClick={() => setScore(n)}
>
{n}
</button>
))}
</div>
<button onClick={onBack}>Back</button>
<button
disabled={score === null}
onClick={() => onNext({ score })}
>
Continue
</button>
</div>
)
}Custom steps don't have an accept/decline mechanic, so there's no error or isProcessing prop. If your step does asynchronous work — uploading a screenshot, calling an API — manage that state inside the component.
Whatever you pass to onNext(result) lands on customStepResults[step.type] in the session payload. In analytics and connected modes it surfaces in the dashboard and webhook events; in pure open source there's nowhere to send it — fire your own analytics from the custom component before calling onNext.
Custom offer
Custom offers are richer than custom steps: they attach to survey reasons, they have accept/decline mechanics, and their accepts flow through onAccept instead of a dedicated handle<Type>.
<CancelFlow
steps={[
{
type: 'survey',
reasons: [
{
id: 'too-many-seats',
label: 'Too many seats',
offer: {
type: 'change-seats',
data: { currentSeats: 10, minSeats: 1, pricePerSeat: 10 },
},
},
{
id: 'expensive',
label: 'Too expensive',
offer: {
type: 'discount',
couponId: 'STRIPE_SAVE20',
percentOff: 20,
durationInMonths: 3,
},
},
],
},
{ type: 'confirm' },
]}
customComponents={{
'change-seats': ({ offer, onAccept, onDecline, isProcessing }) => (
<SeatPicker
currentSeats={offer.data.currentSeats}
minSeats={offer.data.minSeats}
pricePerSeat={offer.data.pricePerSeat}
onConfirm={(seats) => onAccept({ seats })}
onDecline={onDecline}
isProcessing={isProcessing}
/>
),
}}
onAccept={async (offer) => {
if (offer.type === 'change-seats') {
const { seats } = offer.result as { seats: number }
await myBilling.changeSeats(seats)
}
}}
handleCancel={handleCancel}
/>Custom offers go through onAccept because the SDK can't infer what "applying" a custom offer means — it could call a billing API, queue a job, or write to a sibling system. Narrow on offer.type inside onAccept to dispatch on the specific custom type. The result you pass to your component's onAccept(result) lands on offer.result in the catch-all callback and on the recorded session.
Custom offer props
interface CustomOfferProps {
offer: OfferDecision // your CustomOfferConfig plus server-supplied `copy`
customer: DirectCustomer | null
subscriptions: DirectSubscription[]
onAccept: (result?: object) => Promise<void>
onDecline: () => void
isProcessing: boolean
}offer is an OfferDecision: your CustomOfferConfig (with the data you set on it) plus the SDK's copy object (headline, body, CTA, decline CTA). In connected mode the copy is server-driven; in open source it falls back to defaults.
Bind isProcessing to your accept button's disabled attribute. If onAccept throws, the SDK re-renders the step with isProcessing: false and the customer can press the action button again.
Mixing built-in and custom
Built-in and custom types coexist in the same steps array. The SDK dispatches on offer.type: if it matches a built-in, the default for that type renders; otherwise the SDK looks up customComponents[offer.type].
steps: [
{
type: 'survey',
reasons: [
{ id: 'price', label: 'Too expensive', offer: { type: 'discount', /* ... */ } },
{ id: 'seats', label: 'Too many seats', offer: { type: 'change-seats', /* ... */ } },
],
},
{ type: 'confirm' },
]If a custom step or offer references a type you didn't register, the SDK logs a console warning and skips it. A missing registration degrades to a missing step rather than a broken page.
Custom steps alongside server-defined flows
In connected mode, the flow configuration comes from the dashboard. Pass steps alongside session to append or override steps in code:
<CancelFlow
session={token}
steps={[
{ type: 'nps', title: 'Quick question', data: { scale: 10 } },
]}
customComponents={{ nps: NpsStep }}
/>The merge follows two rules:
| Server has step type | Local steps has same type | Result |
|---|---|---|
| Yes | No | Server step renders. |
| Yes | Yes | Local step overrides by type. |
| No | Yes | Local step is appended. |
This makes one pattern clean: keep a custom analytics step in code that appears on every flow regardless of dashboard config.
Next steps
- NPS with faces recipe — a polished version of the NPS step, copy-pasteable.
- Headless — render every step yourself with the useCancelFlow hook.
- Replacing components — override built-in step rendering.