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 stateTypeUse it for
stepstringThe current step type. Switch on it.
currentStepResolvedStep | undefinedFull step config (title, description, losses, data, etc.).
stepIndexnumberZero-based step position. Pair with totalSteps for progress bars.
totalStepsnumberTotal resolved steps in the flow.
reasonsReasonConfig[]Render the survey choices.
selectedReasonstring | nullHighlight the active selection.
followupResponsestringText entered into the follow-up textarea when a freeform: true reason is picked. Empty otherwise.
currentOfferOfferDecision | nullRender the offer step.
feedbackstringRender the feedback textarea value.
outcome'saved' | 'cancelled' | nullRender the success step.
isProcessingbooleanDisable buttons while a handler is in flight.
errorError | nullShow an inline error and let the customer retry.
customerDirectCustomer | nullThe customer passed to the hook, or null if none.
subscriptionsDirectSubscription[]The subscriptions passed to the hook, or empty.
Returned actionSignatureUse it for
selectReason(id)(id: string) => voidSurvey selection.
next(result?)(result?: object) => voidAdvance. The result is captured for custom steps.
back()() => voidGo back. No-op on the first step.
accept(result?)(result?: object) => Promise<void>Accept the current offer.
decline()() => voidDecline the offer.
cancel()() => Promise<void>Confirm cancellation.
close()() => voidMark the flow as abandoned.
setFeedback(text)(text: string) => voidUpdate the feedback value.
setFollowupResponse(text)(text: string) => voidUpdate the follow-up text for the active freeform: true reason.
retry()() => voidConnected 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