Skip to content

React

Provider, hooks, embed props, and the Next.js wiring that goes with them.

@wajub/react gives you a provider, a CheckoutEmbed, seven field components and four hooks. What every option does is documented on Display the checkout and Payment fields. This page covers what is specific to React.

@wajub/react

Stable · GA

npm

Version
1.8.1
Runtime
React 18+ · Next.js 13+

Covers

  • Payments
  • Payment Links

Not in this package: Billing, Transfers, Sync, Shield, Tax.

Install

@wajub/js is a peer dependency, so it goes on the same line. The package is ESM only.

npm install @wajub/react @wajub/js

The provider

Wrap the checkout in one WajubProvider. It covers a whole subtree, so it belongs at the layout or page level rather than around each component.

app/checkout/layout.tsx
'use client';

import { WajubProvider } from '@wajub/react';

export default function CheckoutLayout({ children }: { children: React.ReactNode }) {
  return <WajubProvider>{children}</WajubProvider>;
}

Hooks

The safe form
import { useWajubOptional } from '@wajub/react';

function UpgradeButton({ sessionId }: { sessionId: string }) {
  const runtime = useWajubOptional();

  return (
    <button
      disabled={!runtime}
      onClick={() => runtime?.wajub.open({ sessionId, onSuccess: onUpgraded })}
    >
      Upgrade plan
    </button>
  );
}
HookReturnsNotes
useWajub(){ wajub, Wajub, WajubError }Throws while loading, on failure, and with no runtime
useWajubOptional()The same, or nullSafe during SSR and while loading
useLoadWajub(){ runtime, loading, error, loadWajub }loading and error let you render a spinner or a retry
useConfirmPayment(sessionId?){ confirm(instance, options?), isConfirming }Submits a mounted field component

CheckoutEmbed

The hosted checkout inside your own layout. Every EmbeddedConfig option is a prop, plus these four.

app/checkout/CheckoutClient.tsx
'use client';

import { CheckoutEmbed } from '@wajub/react';

export function CheckoutClient({ sessionId }: { sessionId: string }) {
  return (
    <CheckoutEmbed
      sessionId={sessionId}
      layout="tabs"
      minHeight={480}
      onSuccess={() => (window.location.href = '/order/complete')}
      onError={(error) => console.error(error.code, error.message)}
    />
  );
}
PropTypeDefaultWhat it does
sessionIdstringRequiredThe authorization_token from POST /payments
classNamestringnoneClass on the container element
styleCSSPropertiesnoneInline styles on the container
minHeightnumber480Reserved height in pixels while the checkout loads
onReady(instance) => voidnoneReceives the CheckoutInstance to drive afterwards

Props can change after mount, and a new sessionId loads a new checkout.

Payment fields

Seven components render one field group each: CardComponent, MobileMoneyComponent, WalletComponent, PaymentComponent, AddressComponent, BankTransferComponent and CryptoComponent. Every option and callback of ComponentConfig is a prop, plus the props below. ComponentEmbed is the same component with an explicit type, when the choice is made at runtime.

Your own layout, your own button
'use client';

import { useRef } from 'react';
import type { ComponentInstance } from '@wajub/js';
import { PaymentComponent, useConfirmPayment } from '@wajub/react';

export function PayForm({ sessionId }: { sessionId: string }) {
  const { confirm, isConfirming } = useConfirmPayment(sessionId);
  const field = useRef<ComponentInstance | null>(null);

  return (
    <>
      <PaymentComponent
        sessionId={sessionId}
        collectAddress="shipping"
        collectName
        onInstance={(instance) => (field.current = instance)}
      />
      <button
        onClick={() => field.current && confirm(field.current)}
        disabled={isConfirming}
      >
        {isConfirming ? 'Processing…' : 'Pay now'}
      </button>
    </>
  );
}
PropTypeDefaultWhat it does
typeComponentTypeSet by the aliasOnly on ComponentEmbed
loader'skeleton' or 'none''skeleton''skeleton': the container holds the form's height from the first render, with a placeholder in your inputs' style. 'none': no placeholder
minHeightnumberUnsetThe container's minimum height in pixels. A minHeight in style counts the same
readyTimeoutnumberUnsetMilliseconds to wait for the form. Past it, onLoadError receives a WajubError with the code ready_timeout. Unset, no limit
onInstance(instance | null) => voidnoneFires on mount and again with null on unmount
onReady(instance) => voidnoneFires once the field is interactive
onStateChange({ state, method }) => voidnoneThe payment moved to another step, APPROVAL_REQUIRED while the payer approves on the phone for example
onResize({ height }) => voidnoneThe field took a new height, in pixels
onRedirect({ url, method }) => voidnoneThe payment continues on the provider's own page, and your page is about to go there
onBeforeSubmit() => boolean | Promise<boolean>noneRuns before the charge, to place your order: false, or an error, charges nothing. Set it from the field's first render

Mobile Money and Card in your own checkout

Your checkout keeps its own form, its method choice and its Pay button, and each method shows only its inputs, in your page's style. The steps, the server calls and the reason for each option are on Payment fields. Here, your server has prepared the payment with deferred: true when the checkout opened, and handed the page its authorization_token and authorization_url.

app/checkout/PaymentStep.tsx
'use client';

import { useRef, useState } from 'react';
import { CardComponent, MobileMoneyComponent, useConfirmPayment, type ComponentInstance } from '@wajub/react';

// The payment is under way, or waits on the payer: nothing on the page may change.
const RUNNING = [
  'PROCESSING', 'VERIFYING', 'PENDING', 'OTP_REQUIRED', 'PIN_REQUIRED', 'PHONE_REQUIRED',
  'BIRTHDAY_REQUIRED', 'ADDRESS_REQUIRED', '3DS_REQUIRED', 'USSD_REQUIRED', 'APPROVAL_REQUIRED',
];

// Your server checks the cart, places the order and activates the payment.
async function placeOrder() {
  const response = await fetch('/checkout/place-order', { method: 'POST' });
  return response.ok; // false: nothing is charged
}

// Rendered inside <WajubProvider>. sessionId and hostedUrl: the prepared payment's
// authorization_token and authorization_url, from your server.
export function PaymentStep({ sessionId, hostedUrl }: { sessionId: string; hostedUrl: string }) {
  const { confirm, isConfirming } = useConfirmPayment(sessionId);
  const component = useRef<ComponentInstance | null>(null);
  const [method, setMethod] = useState<'mobileMoney' | 'card'>('mobileMoney');
  const [complete, setComplete] = useState(false);
  const [step, setStep] = useState('INITIATED');
  const [fallback, setFallback] = useState(false);
  const locked = isConfirming || RUNNING.includes(step);
  const Fields = method === 'card' ? CardComponent : MobileMoneyComponent;

  async function pay() {
    if (fallback) {
      // A prepared payment is charged only once activated: place the order first.
      if (await placeOrder()) window.location.assign(hostedUrl);
      return;
    }
    if (!component.current) return;
    try {
      await confirm(component.current);
      window.location.assign('/thanks');
    } catch {
      // The form shows the reason, and the payer can try again in it.
    }
  }

  return (
    <>
      <fieldset disabled={locked}>
        <label>
          <input type="radio" checked={method === 'mobileMoney'} onChange={() => setMethod('mobileMoney')} />
          Mobile Money
        </label>
        <label>
          <input type="radio" checked={method === 'card'} onChange={() => setMethod('card')} />
          Card
        </label>
      </fieldset>

      {fallback ? (
        <p>You will finish on Wajub's secure payment page.</p>
      ) : (
        <Fields
          sessionId={sessionId}
          variant="fields"
          appearance={{ inherit: true }}
          // loader="skeleton" is the default: the box keeps its height while the form loads.
          readyTimeout={10000}
          fields={method === 'card'
            ? { customer: 'external' }
            : { customer: 'external', country: 'CM', operator: 'detect' }}
          onBeforeSubmit={placeOrder}
          onInstance={(instance) => {
            component.current = instance;
            setComplete(false);
          }}
          onChange={(event) => setComplete(Boolean(event.complete))}
          onStateChange={({ state }) => setStep(state)}
          onLoadError={(error) => {
            if (error.code === 'ready_timeout') setFallback(true);
          }}
        />
      )}

      {step === 'APPROVAL_REQUIRED' && <p role="status">Approve the payment on your phone.</p>}
      <button type="button" disabled={locked || (!fallback && !complete)} onClick={pay}>
        Pay
      </button>
    </>
  );
}

The fields show a declined payment themselves, and the payer tries again in them. When they do not come up within readyTimeout, the Pay button places the order and then sends the payer to Wajub's page: a prepared payment is charged only once activated. appearance.inherit reads a text input of your page, its label, your main button and the background behind the fields, so render your own form's fields before this step, or name them with appearance={{ inherit: { input: '#email', button: '#pay' } }}. Mark the order paid on your server, from the payment.succeeded webhook, never from the browser.

Next.js

The session is created on your server. Nothing in this package needs a secret key, and nothing should ever receive one.

app/api/checkout/session/route.ts
import { NextResponse } from 'next/server';
import { Wajub } from '@wajub/node';

const wajub = new Wajub({ secretKey: process.env.WAJUB_API_KEY! });

export async function POST(req: Request) {
  const { cartId } = await req.json();

  const payment = await wajub.payments.create(
    {
      amount: 25000,
      currency: 'XAF',
      description: `Cart ${cartId}`,
      customer: { email: 'buyer@example.com' },
      callback: `${process.env.NEXT_PUBLIC_APP_URL}/order/return`,
      metadata: { cart_id: cartId },
    },
    { idempotencyKey: `cart-${cartId}` },
  );

  return NextResponse.json({ sessionId: payment.authorization_token });
}

Three more things the App Router asks for:

  • Every component on this page is a client component. Mark the file 'use client', or import it from one that is.
  • Put WajubProvider in a layout, so that it stays mounted while the payer moves through the checkout flow.
  • Pass sessionId down as a prop from a server component, or fetch it from the route above.

What the package exports

The exports are listed in the SDK reference, the types the package adds in TypeScript.

What did you think of this content?