Docs navigation
Headless
useCancelFlow exposes the cancel flow's state and actions as a React hook. The SDK doesn't render any UI; you do. Use it when each step needs its own URL or when the flow embeds inline alongside other content.
Before you begin
You're comfortable with React state.
1. Import the hook
import { useCancelFlow } from '@churnkey/react/headless'The hook accepts the same configuration as <CancelFlow> minus the rendering props (appearance, classNames, components). All other props — steps, callbacks, mode-selection props, custom components — pass through unchanged.
2. Drive the flow
The hook returns one object containing state, actions, and (in connected mode) loading status. Switch on flow.step to choose what to render.
function CancelPage() {
const flow = useCancelFlow({
steps: [
{
type: 'survey',
reasons: [
{
id: 'expensive',
label: 'Too expensive',
offer: {
type: 'discount',
couponId: 'STRIPE_SAVE20',
percentOff: 20,
durationInMonths: 3,
},
},
],
},
{ type: 'feedback' },
{ type: 'confirm' },
],
handleDiscount: async (offer) => myBilling.applyCoupon(offer.couponId),
handleCancel: async () => myBilling.cancel(),
})
if (flow.isLoading) return <Spinner />
if (flow.loadError) return <ErrorBanner onRetry={flow.retry} />
switch (flow.step) {
case 'survey':
return <MySurvey flow={flow} />
case 'offer':
return <MyOffer flow={flow} />
case 'feedback':
return <MyFeedback flow={flow} />
case 'confirm':
return <MyConfirm flow={flow} />
case 'success':
return <MySuccess flow={flow} />
default:
return <MyCustomStep flow={flow} type={flow.step} />
}
}flow.step is the current step's type string — 'survey', 'offer', etc. for built-in types, or your custom type for custom steps. Render custom step components from the default case.
isLoading and loadError apply only in connected mode, while the SDK fetches the flow configuration. In open source they're always false and null.
3. Render each step
A step component reads state from flow and updates it through the actions. The state matches what <CancelFlow components={...}> passes to per-step overrides.
function MySurvey({ flow }: { flow: ReturnType<typeof useCancelFlow> }) {
return (
<div className="grid gap-4">
<h2>Why are you leaving?</h2>
<div className="grid grid-cols-2 gap-2">
{flow.reasons.map((r) => (
<button
key={r.id}
data-selected={flow.selectedReason === r.id}
onClick={() => flow.selectReason(r.id)}
>
{r.label}
</button>
))}
</div>
<button onClick={flow.next} disabled={!flow.selectedReason}>
Continue
</button>
</div>
)
}
function MyOffer({ flow }: { flow: ReturnType<typeof useCancelFlow> }) {
const offer = flow.currentOffer
if (!offer) return null
return (
<div>
<h2>{offer.copy.headline}</h2>
<p>{offer.copy.body}</p>
<button onClick={flow.accept} disabled={flow.isProcessing}>
{flow.isProcessing ? 'Applying…' : offer.copy.cta}
</button>
<button onClick={flow.decline}>{offer.copy.declineCta}</button>
{flow.error && <p className="error">{flow.error.message}</p>}
</div>
)
}The state and actions:
| Returned state | Type | Use it for |
|---|---|---|
step | string | The current step type. Switch on it. |
currentStep | ResolvedStep | undefined | Full step config (title, description, losses, data, etc.). |
stepIndex | number | Zero-based step position. Pair with totalSteps for progress bars. |
totalSteps | number | Total resolved steps in the flow. |
reasons | ReasonConfig[] | Render the survey choices. |
selectedReason | string | null | Highlight the active selection. |
followupResponse | string | Text entered into the follow-up textarea when a freeform: true reason is picked. Empty otherwise. |
currentOffer | OfferDecision | null | Render the offer step. |
feedback | string | Render the feedback textarea value. |
outcome | 'saved' | 'cancelled' | null | Render the success step. |
isProcessing | boolean | Disable buttons while a handler is in flight. |
error | Error | null | Show an inline error and let the customer retry. |
customer | DirectCustomer | null | The customer passed to the hook, or null if none. |
subscriptions | DirectSubscription[] | The subscriptions passed to the hook, or empty. |
| Returned action | Signature | Use it for |
|---|---|---|
selectReason(id) | (id: string) => void | Survey selection. |
next(result?) | (result?: object) => void | Advance. The result is captured for custom steps. |
back() | () => void | Go back. No-op on the first step. |
accept(result?) | (result?: object) => Promise<void> | Accept the current offer. |
decline() | () => void | Decline the offer. |
cancel() | () => Promise<void> | Confirm cancellation. |
close() | () => void | Mark the flow as abandoned. |
setFeedback(text) | (text: string) => void | Update the feedback value. |
setFollowupResponse(text) | (text: string) => void | Update the follow-up text for the active freeform: true reason. |
retry() | () => void | Connected mode only. Re-fetch the dashboard-configured flow after loadError. |
isProcessing is true while the hook awaits a handle<Type> and false otherwise. Bind it to the disabled attribute on your accept and cancel buttons. If a handler throws, flow.error is set and isProcessing returns to false, letting the customer retry.
Mixing with <CancelFlow>
The hook and the component share a state machine. Render <CancelFlow> for standard flows and useCancelFlow for ones that need custom rendering. Session recording and analytics behave identically across both.
Next steps
- Headless API reference — all return values, actions, and types.
- Custom step types — extending the state machine.