State management with XState Actors

An inline Paddle checkout includes three distinct steps:

  1. Collect user's email and physical address
  2. Proceed to payment by adding card details
  3. Confirmation

@paddle/paddle-js allows listening to events at each step of the checkout flow. By using xstate we are going to keep track of the current checkout state and update the UI accordingly.

The first step is loading the correct product and its priceId from the API. We implement this step in a loadProductActor that accepts the product's slug as input:

const loadProductActor = fromPromise(
  ({
    input: { slug },
  }: {
    input: {
      slug: string;
    };
  }) =>
    RuntimeClient.runPromise(
      Effect.gen(function* () {
        const api = yield* Api;
        // 👇 Type-safe HTTP client using `Api` service
        return yield* api.paddle.product({ path: { slug } });
      })
    )
);

If we find a valid product, the next step is using the Paddle service to initialize the checkout. We implement this step in another paddleInitActor:

const paddleInitActor = fromPromise(
  ({
    input: { clientToken },
  }: {
    input: {
      clientToken: string;
    };
  }) =>
    RuntimeClient.runPromise(
      Effect.gen(function* () {
        // 👇 Use `Paddle` service to initialize the checkout (`initializePaddle`)
        const _ = yield* Paddle;
        return yield* _({ clientToken });
      })
    )
);

The returned Paddle client is stored inside the machine's context. Using the client and the priceId from the API, we call Checkout.open to initialize the checkout and Update to listen to callback events:

  • Checkout.open instructs Paddle to start the checkout flow
  • Update allows to provide a new eventCallback function that will be called each time a checkout event is triggered
entry: [
  ({ context, self }) =>
    context.paddle?.Update({
      eventCallback: (event) => {
        if (event.name === CheckoutEventNames.CHECKOUT_CUSTOMER_CREATED) {
          self.send({ type: "checkout.created" });
        } else if (event.name === CheckoutEventNames.CHECKOUT_COMPLETED) {
          self.send({ type: "checkout.completed" });
        }
      },
    }),
  ({ context }) =>
    context.paddle?.Checkout.open({
      items: [{ priceId: context.price?.id!, quantity: 1 }],
    }),
]

With this implementation all the steps during the checkout are synced with the state machine. Later inside the component we will display the current checkout state to the user.