Stripe payments React client with XState and Effect

Languages

typescript5.6.3

Libraries

effect3.10.12
nodejs22.8.6
react18.3.1
xstate5.18.2
stripe4.9.0
GithubCode

This snippet implements a Stripe payments form on the client using React with XState and Effect.

The stripe logic is defined in a state machine using xstate. The state machine tracks the current state of the form:

  1. Load Stripe (handle possible loading errors and allow reloading)
  2. Wait for user to fill the form and submit
  3. Send a confirmPayment request to Stripe and report any errors

The stripe client is loaded inside a Stripe service implemented using effect. The service executes loadStripe from @stripe/stripe-js.

The React component uses useMachine from @xstate/react to load the state machine.

Loading stripe requires access to a clientSecret that should be provided by the server.

clientSecret is required as input to the state machine.

The component renders Elements from @stripe/react-stripe-js. The code uses useElements and useStripe.

useElements and useStripe can only be used inside a subcomponent wrapped inside Elements.

The form contains AddressElement and PaymentElement from @stripe/react-stripe-js. When the form is submitted, the state machine is triggered with the stripe.submit event.

When the payment is successful stripe will redirect to a success page defined when calling confirmPayment (return_url).


References

import type { StripeElements } from "@stripe/stripe-js";
import { Effect, type Context } from "effect";
import { assertEvent, assign, fromPromise, setup } from "xstate";
import { RuntimeClient } from "./runtime-client";
import { Stripe } from "./stripe";

// 👉 Link to testing cards: https://docs.stripe.com/testing#cards
export const machine = setup({
  types: {
    input: {} as { clientSecret: string },
    events: {} as
      | { type: "stripe.reload" }
      | {
          type: "stripe.submit";
          event: React.FormEvent<HTMLFormElement>;
          elements: StripeElements | null;
        },
    context: {} as {
      options: { clientSecret: string };
      stripe: Context.Tag.Service<typeof Stripe> | null;
      stripeError: string | null;
    },
  },
  actors: {
    loadStripe: fromPromise(() => RuntimeClient.runPromise(Stripe)),
    confirmPayment: fromPromise(
      ({
        input: { elements, stripe },
      }: {
        input: {
          stripe: Context.Tag.Service<typeof Stripe> | null;
          elements: StripeElements | null;
        };
      }) =>
        RuntimeClient.runPromise(
          Effect.gen(function* () {
            if (stripe === null || elements === null) {
              return yield* Effect.fail("Stripe not loaded");
            }

            const { error } = yield* Effect.tryPromise({
              try: () =>
                stripe.confirmPayment({
                  elements,
                  confirmParams: {
                    return_url: "/complete", // TODO
                  },
                }),
              catch: () => "Payment failed, please try again",
            });

            return yield* Effect.fail(error.message ?? "Unknown error");
          })
        )
    ),
  },
}).createMachine({
  id: "stripe-elements-machine",
  context: ({ input }) => ({
    options: { clientSecret: input.clientSecret },
    stripe: null,
    stripeError: null,
  }),
  initial: "Loading",
  states: {
    Loading: {
      invoke: {
        src: "loadStripe",
        onError: { target: "LoadingError" },
        onDone: {
          target: "Idle",
          actions: assign(({ event }) => ({
            stripe: event.output,
          })),
        },
      },
    },
    Idle: {
      always: {
        target: "Loading",
        // 👇 Only allow `Idle` state if `Stripe` is loaded
        guard: ({ context }) => context.stripe === null,
      },
      on: {
        "stripe.submit": {
          target: "Submitting",
          actions: assign({ stripeError: null }),
        },
      },
    },
    Submitting: {
      invoke: {
        src: "confirmPayment",
        input: ({ event, context }) => {
          assertEvent(event, "stripe.submit");
          event.event.preventDefault();
          return {
            elements: event.elements,
            stripe: context.stripe,
          };
        },
        onError: {
          target: "Idle",
          actions: assign(({ event }) => ({
            stripeError: String(
              // 👇 Report errors from Stripe
              event.error instanceof Error ? event.error.message : event.error
            ),
          })),
        },
        onDone: {
          target: "Complete",
        },
      },
    },
    LoadingError: {
      on: {
        "stripe.reload": {
          target: "Loading",
          actions: assign({ stripeError: null }),
        },
      },
    },
    Complete: {
      type: "final",
    },
  },
});