@churnkey/react

中文文档
0.10.0发布于 2026/11/03MITTypeScriptESM + CJS零依赖构建来源已验证近 30 天下载 4873

Production-ready cancel flow for React. Drop-in component, headless hook, or full customization. Works standalone or with Churnkey for AI-powered retention.

原始使用文档
文档目录15 节
  1. Installation
  2. Create a cancel flow
  3. Change the look
  4. Replace components
  5. Add custom steps
  6. Prebuilt custom-offer components
  7. Go headless
  8. Add analytics
  9. Let Churnkey handle billing
  10. Handlers vs. listeners
  11. Pairs of offers
  12. Passing customer/subscription data alongside a token
  13. Mixing local and server steps
  14. Imports
  15. License

@churnkey/react

A cancel flow component for React. Survey, offers, feedback, and confirmation. Ready to use out of the box, fully customizable when you need it.

Open source. Optionally connects to Churnkey for analytics and AI-powered retention.

Installation

npm install @churnkey/react

Create a cancel flow

Import the component and the stylesheet, define your steps, and handle the result.

import { CancelFlow } from '@churnkey/react'
import '@churnkey/react/styles.css'

<CancelFlow
  steps={[
    {
      type: 'survey',
      title: 'Why are you leaving?',
      reasons: [
        {
          id: 'expensive',
          label: 'Too expensive',
          offer: {
            type: 'discount',
            couponId: 'STRIPE_SAVE20',  // your coupon ID
            percentOff: 20,             // for the UI
            durationInMonths: 3,        // for the UI
          },
        },
        {
          id: 'not-using',
          label: 'Not using it enough',
          offer: { type: 'pause', months: 2 },
        },
        { id: 'missing', label: 'Missing features' },
      ],
    },
    { type: 'feedback', title: 'Anything else?' },
    { type: 'confirm' },
  ]}
  handleDiscount={async (offer) => myBilling.applyCoupon(offer.couponId)}
  handlePause={async (offer) => myBilling.pause({ months: offer.result?.months ?? offer.months })}
  handleCancel={async () => myBilling.cancel()}
  onClose={() => setOpen(false)}
/>

When a customer selects "Too expensive," the SDK shows the discount offer. If they accept, handleDiscount runs. If they decline all offers and confirm, handleCancel runs.

Change the look

The SDK ships light and dark schemes. 'auto' follows the user's OS preference and reacts to changes.

<CancelFlow
  appearance={{ colorScheme: 'auto' }}
  steps={steps}
  onAccept={handleOffer}
  onCancel={handleCancel}
/>

Override individual design tokens via appearance.variables. The same value applies in both schemes — set your brand color once and it shows up everywhere.

appearance={{
  variables: { colorPrimary: '#7c3aed', borderRadius: '12px' },
}}

For class-based styling (Tailwind, CSS modules), use classNames:

classNames={{
  modal: 'max-w-lg shadow-2xl',
  overlay: 'bg-black/60 backdrop-blur-sm',
}}

Replace components

Swap out any piece of the UI. The SDK handles navigation and state; you handle rendering.

<CancelFlow
  steps={steps}
  components={{
    ReasonButton: ({ reason, isSelected, onSelect }) => (
      <button
        onClick={() => onSelect(reason.id)}
        className={isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}
      >
        {reason.label}
      </button>
    ),
  }}
  onAccept={handleOffer}
  onCancel={handleCancel}
/>

You can replace Modal, CloseButton, BackButton, the step components (Survey, Offer, Feedback, Confirm, Success), ReasonButton, per-offer-type components (DiscountOffer, PauseOffer, PlanChangeOffer, TrialExtensionOffer, ContactOffer, RedirectOffer), and StackedOffer for a pair of offers.

Add custom steps

The step system is open. Use any string as a step type, then register a component for it.

<CancelFlow
  steps={[
    { type: 'survey', reasons: [
      { id: 'seats', label: 'Too many seats',
        offer: { type: 'change-seats', data: { minSeats: 1 } } },
    ]},
    { type: 'nps', title: 'One quick question', data: { scale: 10 } },
    { type: 'feedback' },
    { type: 'confirm' },
  ]}
  customComponents={{
    'nps': ({ step, onNext }) => (
      <NpsRating scale={step.data.scale} onSubmit={(score) => onNext({ score })} />
    ),
    'change-seats': ({ offer, onAccept }) => (
      <SeatAdjuster onConfirm={(seats) => onAccept({ seats })} />
    ),
  }}
  onAccept={handleOffer}
  onCancel={handleCancel}
/>

Custom steps navigate like built-in ones. Custom offers appear when a matching reason is selected. Whatever you pass to onNext(result) or onAccept(result) shows up on offer.result in your onAccept handler, and is recorded with the session for analytics (as customStepResults[stepType] for steps, acceptedOffer.customOfferResult for offers).

Prebuilt custom-offer components

If you'd rather not build a custom offer's UI yourself, import a prebuilt one from @churnkey/react/recipes. They render with the same styling system as the built-in offers and pass their result through onAccept, so you only implement the billing side.

import { TermExtensionOffer, PassthroughOffer } from '@churnkey/react/recipes'

<CancelFlow
  customComponents={{
    // "Delay your next charge by N days". Reads offer.data.days, shows the
    // new renewal date, passes { days } through onAccept.
    annual_term_extension: TermExtensionOffer,
    // Copy + accept/decline, nothing else. Register it for any offer type
    // whose substance lives entirely in the flow-authored copy; offer.data
    // passes through as the result.
    scheduled_transfer: PassthroughOffer,
    immediate_transfer: PassthroughOffer,
  }}
  onAccept={handleOffer}
  onCancel={handleCancel}
/>

The components live behind a subpath export, so they add nothing to your bundle unless imported. For full presentation control, copy a template from packages/react/examples/ into your codebase instead and own it from there.

Go headless

Use the hook directly if you want full control over the UI.

import { useCancelFlow } from '@churnkey/react/headless'

function MyCancelPage() {
  const flow = useCancelFlow({ steps, onAccept: handleOffer, onCancel: handleCancel })

  if (flow.step === 'survey') {
    return (
      <div>
        {flow.reasons.map((r) => (
          <button key={r.id} onClick={() => flow.selectReason(r.id)}>{r.label}</button>
        ))}
        <button onClick={flow.next} disabled={!flow.selectedReason}>Continue</button>
      </div>
    )
  }

  if (flow.step === 'offer' && flow.currentOffer) {
    return (
      <div>
        <h2>{flow.currentOffer.copy.headline}</h2>
        <button onClick={flow.accept}>Accept</button>
        <button onClick={flow.decline}>No thanks</button>
      </div>
    )
  }

  // ... feedback, confirm, success
}

The hook returns the current state (step, reasons, currentOffer, feedback, outcome, isProcessing) and actions (selectReason, next, back, accept, decline, cancel, close).

In token mode the hook leaves pairs of offers out of the flow unless you pass stackedOffers: true; see Pairs of offers.

Add analytics

Create a free Churnkey account and pass your appId. The SDK records each session so you can see why customers cancel, which offers work, and what your save rate looks like.

<CancelFlow
  appId="app_xxx"
  customer={{ id: 'cus_123' }}
  steps={steps}
  onAccept={handleOffer}
  onCancel={handleCancel}
/>

Only customer.id is required. For revenue metrics, pass subscription data too:

<CancelFlow
  appId="app_xxx"
  customer={{ id: 'cus_123', email: '[email protected]' }}
  subscriptions={[{
    id: 'sub_456',
    start: '2024-06-01',
    status: { name: 'active', currentPeriod: { start: '2025-04-01', end: '2025-05-01' } },
    items: [{ price: { id: 'price_pro', amount: { value: 2999 } } }],
  }]}
  steps={steps}
  onAccept={handleOffer}
  onCancel={handleCancel}
/>

Your steps and callbacks don't change. No backend work required.

Sessions are recorded when the customer accepts an offer, confirms cancellation, or closes the modal before completing (abandoned). Data from custom steps passed via onNext(result) is captured alongside the session.

To keep staging traffic out of your production analytics, pass mode="test":

<CancelFlow
  appId="app_xxx"
  customer={{ id: 'cus_123' }}
  mode={process.env.NODE_ENV === 'production' ? 'live' : 'test'}
  steps={steps}
  onAccept={handleOffer}
  onCancel={handleCancel}
/>

Defaults to 'live'. If your org is connected to a Stripe Sandbox rather than classic test mode, pass mode="sandbox" instead so server-side billing actions use your sandbox credentials. In token mode, the token's mode takes precedence — it's server-signed and can't be overridden client-side.

Let Churnkey handle billing

Connect your billing provider (Stripe, Chargebee, etc.) in the Churnkey dashboard and Churnkey can apply discounts, pause subscriptions, and cancel on your behalf. Generate a token on your server to authenticate the session.

// Server
import { Churnkey } from '@churnkey/node'

const ck = new Churnkey({ appId: 'app_xxx', apiKey: 'sk_xxx' })
const token = ck.createToken({ customerId: 'cus_123' })
// Client
<CancelFlow
  appId="app_xxx"
  customer={{ id: 'cus_123', email: '[email protected]' }}
  subscriptions={[...]}
  session={token}
  onAccept={async (offer) => console.log('Applied:', offer)}
  onCancel={async () => router.push('/goodbye')}
/>

In this mode, the cancel flow is configured from the Churnkey dashboard. Your custom components and appearance settings carry over.

Handlers vs. listeners

Two kinds of callbacks, distinguished by name:

  • handle<Type> — runs the action. Replaces what Churnkey would do on the server. In local mode (no token) this is the only thing that runs the action.
  • on<Type> — fires after the action, regardless of who ran it. Side effects only — analytics, refetch, toasts.
<CancelFlow
  session={token}
  // Handler: defining handlePause tells the SDK NOT to call its own
  // server-side pause; you run the action instead. Skip handle* and
  // Churnkey takes the action automatically in token mode.
  handlePause={async (offer, customer) => {
    await myBilling.pause({ months: offer.result?.months ?? offer.months })
  }}
  // Listener: fires after the pause completes (whoever ran it).
  onPause={(offer, customer) => analytics.track('paused', { months: offer.result?.months ?? offer.months })}
  onAccept={(offer) => analytics.track('offer_accepted', { type: offer.type })}
  onCancel={() => router.push('/goodbye')}
/>

Available handlers: handleDiscount, handlePause, handlePlanChange, handleTrialExtension, handleCancel. Available listeners: onDiscount, onPause, onPlanChange, onTrialExtension, onCancel, plus the catch-all onAccept that fires for any accepted offer.

In local mode (no token), there's no server action — handlers do the work. In token mode, defining a handler opts out of Churnkey running the action and gives the work back to you.

Pairs of offers

An offer can carry a second one (offer.stackedOffer), set up in the dashboard. The SDK shows one card per offer and a single "Accept both offers" button; replace it through the StackedOffer component slot.

Churnkey sends pairs only to a UI that shows both offers. With your own Offer component, or a customComponents entry for a built-in offer type, pairs are left out of the flow unless you also pass StackedOffer. With useCancelFlow, pass stackedOffers: true once your UI renders offer.stackedOffer.

  • Without handlers, token mode applies both offers in one request. After a pause or a trial extension, the second offer starts when the pause or trial ends.
  • With handlers, each offer goes through its own handler, first offer first, except that a rebate runs last. If the offer that runs second fails, the flow still completes, acceptedOffer.stackedOffer.stackStatus is 'failed' and failedOfferType names the offer that did not apply. Only Churnkey can hold the second offer while a pause or trial runs, so a pair whose second offer waits is left out of the flow in local mode and when a handler covers either offer. In token mode a pair is also left out when a handler covers only one of its offers, because Churnkey applies an offer of a pair only together with the other one.
  • Listeners and onAccept get each offer on its own, once per offer that applied.
  • A StackedOffer component accepts the pair with one result: months for the pause and planId for the plan change. Each offer's result gets only its own pick, plus any other keys you pass.

Passing customer/subscription data alongside a token

You can pass customer and subscriptions together with session to enrich the session record with client-side data Churnkey doesn't already have (custom metadata, current plan price, etc.). The token's signed customer ID stays authoritative; direct data fills in the gaps.

<CancelFlow
  appId="app_xxx"
  customer={{ id: 'cus_123', email: '[email protected]', metadata: { plan: 'pro' } }}
  subscriptions={[...]}
  session={token}
  onAccept={handleOffer}
  onCancel={handleCancel}
/>

Mixing local and server steps

You can pass both session and steps to override specific server config:

<CancelFlow
  session={token}
  steps={[
    { type: 'confirm', title: 'We hate to see you go' },
    { type: 'nps', title: 'Quick question', data: { scale: 10 } },
  ]}
  customComponents={{ 'nps': NpsStep }}
  onAccept={handleOffer}
  onCancel={handleCancel}
/>

Local steps override by type. Steps not in the server config are appended.

Imports

import { CancelFlow } from '@churnkey/react'              // drop-in component
import { useCancelFlow } from '@churnkey/react/headless'   // headless hook
import { CancelFlowMachine } from '@churnkey/react/core'   // state machine, no React
import { TermExtensionOffer } from '@churnkey/react/recipes' // prebuilt custom-offer components

License

MIT

版本与发布记录

@churnkey/react 共发布 23 个版本,首次发布于 2026/05/01;过去 12 个月发布了 23 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.10.0latest2026/11/036 天前
0.9.12026/10/2911 天前
0.9.02026/10/2218 天前
0.8.12026/10/091 个月前
0.8.02026/07/223 个月前
0.7.22026/07/163 个月前

关于 @churnkey/react 的常见问题

@churnkey/react 是什么?

Production-ready cancel flow for React. Drop-in component, headless hook, or full customization. Works standalone or with Churnkey for AI-powered retention.

如何安装 @churnkey/react?

在项目目录运行 npm install @churnkey/react,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(^18.0.0 || ^19.0.0)、react-dom(^18.0.0 || ^19.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@churnkey/react 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@churnkey/react 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@churnkey/react 可以商用吗?

@churnkey/react 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@churnkey/react 的下载量有多少?

根据 npm 官方下载统计,@churnkey/react 在 2026/10/10 – 2026/11/08 这 30 天内被下载 4,873 次,日均约 162 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@churnkey/react 还在维护吗?

最新版本 0.10.0 发布于 2026/11/03(6 天前);至今共发布 23 个版本,其中过去 12 个月发布了 23 个;首次发布于 2026/05/01。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。