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:

PropTypeWhat it is
step{ type, title?, description?, data? }The step config you passed in.
customerDirectCustomer | nullThe customer passed to <CancelFlow>, or null if none.
subscriptionsDirectSubscription[]The subscriptions passed to <CancelFlow>, or empty.
onNext(result?: object) => voidAdvance. The result is captured on the session.
onBack() => voidGo 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 typeLocal steps has same typeResult
YesNoServer step renders.
YesYesLocal step overrides by type.
NoYesLocal 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