React checkout
@checkout-kit/react connects the payment engine to a React 19 app. It does not replace your server API or your form fields. The optional UI kit gives you ready-made visuals, but your own design system works with the same hooks just as well.
Install from npm
npm install @checkout-kit/core @checkout-kit/runtime-browser @checkout-kit/provider-paypal @checkout-kit/react @checkout-kit/ui react@^19 react-dom@^19npm package · All installation options
A complete small example
Copy these two files into your project as quickstart-engine.ts and quickstart.tsx. They are real files from the repository, and the TypeScript build checks them, so they always work. You also need the PayPal routes on your server. Create the engine once in the browser and start the app with this code.
Engine setup:
import { createCheckout, defineProvider } from '@checkout-kit/core'
import { createBrowserRuntime } from '@checkout-kit/runtime-browser'
import type { PayPalConfig } from '@checkout-kit/provider-paypal'
/** Call once in the browser. baseUrl points at the merchant's PayPal API. */
export const createPayPalCheckout = (baseUrl = '/api', returnPath = '/checkout/return') => {
const runtime = createBrowserRuntime({
returnPath,
security: { allowInsecureLocalhost: import.meta.env.DEV },
})
const engine = createCheckout({
providers: [
defineProvider({
id: 'paypal',
config: { baseUrl, credentials: 'include' } satisfies PayPalConfig,
load: () => import('@checkout-kit/provider-paypal'),
}),
],
runners: runtime.runners,
storage: runtime.storage,
returnUrl: runtime.returnUrl,
defaultProviderId: 'paypal',
})
return {
engine,
runtime,
async pay(planId: string) {
// Recovery retains the original intent/key if the merchant API was unavailable.
const restored = await engine.hydrate(runtime.readReturnParams())
return restored ?? engine.pay({ input: { planId }, instrument: { kind: 'none' } })
},
}
}React screen:
import type { CheckoutEngine, PaymentResult } from '@checkout-kit/core'
import { CheckoutProvider, CheckoutRoot, PaymentActionHost, useCheckout } from '@checkout-kit/react'
import { Button, Money, Panel } from '@checkout-kit/ui'
import '@checkout-kit/ui/styles.css'
type Pay = (planId: string) => Promise<PaymentResult>
function Payment({ planId, pay }: { planId: string; pay: Pay }) {
const { engine, phase, intent, error, isBusy, isLocked, isSettled } = useCheckout()
const hasFinalOutcome = intent && ['succeeded', 'declined', 'canceled'].includes(intent.status)
return (
<Panel title="Checkout">
{intent && <Money amount={intent.amount} currency={intent.currency} />}
<p role="status">{phase}</p>
{error && <p role="alert">{error.message}</p>}
<Button busy={isBusy} disabled={isLocked} onClick={() => void pay(planId)}>
Pay with PayPal
</Button>
{phase === 'failed' && (
<Button variant="secondary" onClick={() => void pay(planId)}>
Retry the same payment
</Button>
)}
{isSettled && hasFinalOutcome && (
<Button variant="secondary" onClick={() => engine.reset()}>
Start over
</Button>
)}
<PaymentActionHost />
</Panel>
)
}
export function PayPalCheckout({
engine,
planId,
pay,
}: {
engine: CheckoutEngine
planId: string
pay: Pay
}) {
return (
<CheckoutProvider engine={engine}>
<CheckoutRoot theme="auto">
<Payment planId={planId} pay={pay} />
</CheckoutRoot>
</CheckoutProvider>
)
}How the example works:
- the server sets the price from
planId;Moneyonly shows it; PaymentActionHostruns the PayPal approval step;- if the result is unclear (for example, the network failed), the "try again" button repeats recovery or the same attempt, so the shopper is not charged twice;
- "start over" appears only when the payment has a confirmed final status.
How long the engine lives
Create one engine per checkout, outside of rendering (not inside a component body). If you create a new engine on every render, you lose subscriptions and the payment that is in progress.
Keep PaymentActionHost on the screen while its step is running. If it disappears, the step stops.
CheckoutProvidergives the engine to the hooks below it.CheckoutRootadds the CSS theme, density and platform attributes.
If you use your own UI instead of @checkout-kit/ui, you only need CheckoutProvider.
Read only the state you need
| Hook | What it returns |
|---|---|
useCheckoutEngine() | The engine's commands. The component does not re-render on state changes. |
useCheckoutSnapshot() | The current state: phase, payment, step, error, provider and what it supports. |
useCheckout() | The state plus the engine and the flags isBusy, isSettled and isLocked. |
useCheckoutSelector(selector, isEqual?) | Only the part you choose. Re-renders only when that part changes. You can pass your own comparison. |
usePaymentState({ isDirty, isValidating }) | A state that also knows about your form (changed, being validated). |
import { useCheckoutSelector } from '@checkout-kit/react'
const total = useCheckoutSelector(
(s) => ({ amount: s.intent?.amount, currency: s.intent?.currency }),
(a, b) => a.amount === b.amount && a.currency === b.currency,
)Rules and pitfalls:
- a selector must only read data, without side effects;
isSettledmeans "the engine has stopped". This includes failures and unclear results. It does not mean "paid";isLockedis alsotruein final states. Do not use it alone to disable the retry button forever — add your own condition for recovery or a new attempt.
Extra steps and recovery
PaymentActionHoststarts the step on its own (autoRunis on by default).surfacelets you choose where to show the step, if the provider supports it.onSettledis only for the interface: show a message, go to another page. Delivering the order is the server's job.- With
autoRun={false}, callengine.runPendingActionyourself and pass a mount.
The component protects against running the same step twice, even when the provider sends several steps in a row with the same id.
You can render React and UI components on the server (SSR), but create the browser runtime only in the browser. See runtime and troubleshooting.