<div id="app"></div>
* {
  box-sizing: border-box;
}

body {
  align-items: center;
  background-color: #EDF1F4;
  color: #153D66;
  display: flex;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
  justify-content: center;
  min-height: 100vh;
}

h2 {
  margin: 0;
}

.form-container {
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.125);
}

.form-header {
  padding: 1.75rem 1.75rem 0;
}

.form-body {
  padding: 1.75rem;
}

label {
  display: block;
  margin-bottom: 4px;
  width: 100%;
}

input {
  border-radius: 4px;
  border: 1px solid #A4B6C0;
  box-shadow: none;
  display: block;
  margin-bottom: 16px;
  padding: 10px;
  width: 100%;
}

button:focus,
input:focus {
  border-color: rgb(102, 175, 233);
  box-shadow: rgba(0, 0, 0, 0.075) 0px 1px 1px inset, rgba(102, 175, 233, 0.6) 0px 0px 8px;
  outline: none;
}

button {
  -webkit-appearance: none;
  border-radius: 4px;
  border: 0;
  cursor: pointer;
  display: block;
  font-size: 14px;
  font-weight: bold;
  margin: 1.75rem auto 0;
  padding: 12px 20px;
}

.btn-success {
  background-color: #A6E9DC;
  color: #004C4A;
}

.btn-reset {
  background-color: #FFDFDF;
  color: #711117;
}

.pill-container {
  text-align: center;
}

.state-pill {
  background: #fff;
  border-radius: 20px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.125);
  display: inline-block;
  font-weight: bold;
  margin-bottom: 20px;
  padding: 12px 20px;
  text-align:center;
  width: auto;
}

.alert {
  border-radius: 4px;
  margin-top: 12px;
  margin: 12px auto 0;
  padding: 8px 12px;
  text-align: center;
  width: 85%;
}

.alert.error {
  border: 1px solid #ebcccc;
  color: #a94442;
  background-color: #f2dede;
}

.alert.success {
  background-color: #dff0d8;
  border-color: #d0e9c6;
  color: #3c763d;
}
const { Machine } = xstate;

/**
 * The state machine that will power our payment form.
 *
 * This machine can be visualized copying the object we
 * pass to `Machine()` through xviz
 *
 * @see  https://bit.ly/xstate-viz
 */
const stateMachine = Machine({
  initial: "idle",
  states: {
    idle: {
      on: {
        SUBMIT: [
          {
            target: "loading",
            cond: ({ state }) => state.name !== "" && state.card !== ""
          },
          { target: "error" }
        ]
      }
    },
    loading: {
      onEntry: ["doPayment"],
      on: {
        PAYMENT_RECEIVED: "success",
        PAYMENT_FAILED: "error"
      }
    },
    error: {
      on: {
        SUBMIT: {
          target: "loading",
          cond: ({ state }) => state.name !== "" && state.card !== ""
        }
      }
    },
    success: {
      on: {
        RESET: "idle"
      }
    }
  }
});

/**
 * We don't actually have a backend service for this
 * payment system. So, instead we're rolling a Math.random
 * dice and resolving or rejecting a fake payment promise.
 */
function fakePayment() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const dice = Math.floor(Math.random() * Math.floor(2));

      if (dice === 0) return resolve("Payment succeeded.");

      return reject("Payment failed.");
    }, 1000);
  });
}

class App extends React.Component {
  state = {
    machine: {},
    name: "",
    card: "",
    msg: ""
  };

  // Initialize the state machine
  componentDidMount() {
    this.setState({ machine: stateMachine.initialState });
  }

  /**
   * When our machine enters the loading state, fire off
   * a call to our fakePayment() promise.
   */
  doPayment = () => {
    return fakePayment()
      .then(msg => this.transition("PAYMENT_RECEIVED", { msg }))
      .catch(msg => this.transition("PAYMENT_FAILED", { msg }));
  };

  /**
   * We need a way to call the function we define as a string
   * in our onEntry action in the loading state.
   *
   * If you're going to leverage any of the other action types,
   * check out what Michele Bertoli has done in react-automata.
   *
   * @see https://github.com/MicheleBertoli/react-automata/blob/master/src/withStateMachine.js#L79
   */
  runActions = state => {
    if (state.actions.length > 0) {
      state.actions.forEach(f => this[f]());
    }
  };

  // Transition based on current state and extState
  transition = (eventType, extState) => {
    // Determine the next state
    const newState = stateMachine.transition(
      this.state.machine.value,
      eventType,
      {
        state: this.state
      }
    );

    // Perform any actions for the new state
    this.runActions(newState);

    // Update our component state
    this.setState({
      machine: newState,
      msg: extState && extState.msg ? extState.msg : ""
    });
  };

  // Prevent the form from refreshing the page
  submitForm = e => {
    e.preventDefault();
    this.transition("SUBMIT");
  };

  handleInput = e => {
    this.setState({ [e.target.name]: e.target.value });
  };

  render() {
    const { machine, msg } = this.state;

    return (
      <div>
        <div className="pill-container">
          <div className="state-pill">current state: {machine.value}</div>
        </div>

        <div className="form-container">
          <div className="form-header">
            <h2>State Machine Payment Form</h2>
          </div>

          {machine.value === "error" ? (
            <div className="alert error">
              {msg ? msg : "You must fill out all the form fields."}
            </div>
          ) : null}

          {machine.value === "success" ? (
            <div className="alert success">{msg ? msg : null}</div>
          ) : null}

          <div className="form-body">
            <form onSubmit={e => this.submitForm(e)}>
              <div className="form-group">
                <label htmlFor="NameOnCard">Name on card</label>
                <input
                  id="NameOnCard"
                  name="name"
                  className="form-control"
                  type="text"
                  maxlength="255"
                  value={this.state.name}
                  onChange={this.handleInput}
                />
              </div>
              <div className="form-group">
                <label htmlFor="CreditCardNumber">Card number</label>
                <input
                  id="CreditCardNumber"
                  name="card"
                  className="null card-image form-control"
                  type="text"
                  value={this.state.card}
                  onChange={this.handleInput}
                />
              </div>
              <button id="PayButton" className="btn-success" type="submit">
                Pay Now
              </button>
            </form>
          </div>
        </div>

        {machine.value === "success" ? (
          <button
            id="ResetButton"
            className="btn-reset"
            type="button"
            onClick={() => this.transition("RESET")}
          >
            Reset
          </button>
        ) : null}
      </div>
    );
  }
}

ReactDOM.render(<App />, document.getElementById("app"));
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/16.3.0/umd/react.production.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.3.0/umd/react-dom.production.min.js
  3. https://unpkg.com/xstate@next/dist/xstate.js