Building a Generic State Machine for Form Handling Using XState

RobertRobert
4 min read

If you're a computer scientist or follow @davidkpiano you've probably heard about state machines.

They are awesome.

Here's an example of how to use one for form handling!

Our designer says the form should look like this:

4 App States: Editing, Submitting, Error, Success

From this concept we can deduce four "states":

  1. Editing
  2. Submitting
  3. Error
  4. Success

Let's define the states in a machine:

const formMachine = Machine({
  // We'll start in the editing state
  initial: 'editing',
  states: {
    editing: {},
    submitting: {},
    error: {},
    success: {},
  },
})

Editing State

While in the editing state, we can do 2 things:

  • Type in the fields. We stay in the same state. We of course also want the input to be saved.
  • Submit the form. We transition to the submitting state.

Let's define the transitions and actions:

const formMachine = Machine(
  {
    initial: 'editing',
    // Context contains all our infinite state, like text input!
    context: {
      values: {},
    },
    states: {
      editing: {
        on: {
          CHANGE: {
            // Stay in the same state
            target: '',

            // Execute the onChange action
            actions: ['onChange'],
          },
          SUBMIT: 'submitting',
        },
      },
      submitting: {},
      error: {},
      success: {},
    },
  },
  {
    actions: {
      // Assign
      onChange: assign({
        values: (ctx, e) => ({
          ...ctx.values,
          [e.key]: e.value,
        }),
      }),
    },
  },
)

Submitting State

After submitting the form, our life could go one of two ways:

  • The submission is succesful, we move to the success state.
  • The submission failed, we move to the error state.

To keep our machine generic, we'll leave the whatever happens during the submission up to the consumer of the machine by invoking a service. Allowing the consumer to pass in their own service (See Invoking Services). Be it frontend validation, backend validation or no validation, we don't care! The only thing we'll do is transition based on a succesful or unsuccesful response, storing the error data on an unsuccesful response.

const formMachine = Machine(
  {
    initial: 'editing',
    context: {
      values: {},
      errors: {},
    },
    states: {
      editing: {
        on: {
          CHANGE: {
            target: '',
            actions: ['onChange'],
          },
          SUBMIT: 'submitting',
        },
      },
      submitting: {
        invoke: {
          src: 'onSubmit',
          // Move to the success state onDone
          onDone: 'success',
          onError: {
            // Move to the error state onError
            target: 'error',

            // Execute onChange action
            actions: ['onError'],
          },
        },
      },
      error: {},
      success: {},
    },
  },
  {
    actions: {
      onChange: assign({
        values: (ctx, e) => ({
          ...ctx.values,
          [e.key]: e.value,
        }),
      }),
      onError: assign({
        errors: (_ctx, e) => e.data,
      }),
    },
  },
)

Error State

Uh-Oh! We've stumbled upon a few errors. The user can now do two things:

  • Change the inputs.
  • Submit the form again.

Hey, these are the same things we could do in the editing state! Come to think of it, this state is actually pretty similar to editing, only there are some errors in the screen. We could now move the transitions up to the root state, allowing us to ALWAYS change the inputs and ALWAYS submit the form, but obviously we don't want that! We don't want the user to edit the form while it's submitting. What we can do is make the editing state hierarchical with 2 substates: pristine (not submitted) and error (submitted and wrong):

const formMachine = Machine(
  {
    initial: 'editing',
    context: {
      values: {},
      errors: {},
    },
    states: {
      editing: {
        // We start the submachine in the pristine state
        initial: 'pristine',
        // These transitions are available in all substates
        on: {
          CHANGE: {
            actions: ['onChange'],
          },
          SUBMIT: 'submitting',
        },
        // The 2 substates
        states: {
          pristine: {},
          error: {},
        },
      },
      submitting: {
        invoke: {
          src: 'onSubmit',
          onDone: 'success',
          onError: {
            // Note that we now need to point to the error substate of editing
            target: 'editing.error',
            actions: ['onError'],
          },
        },
      },
      success: {},
    },
  },
  {
    actions: {
      onChange: assign({
        values: (ctx, e) => ({
          ...ctx.values,
          [e.key]: e.value,
        }),
      }),
      onError: assign({
        errors: (_ctx, e) => e.data,
      }),
    },
  },
)

Success State

We did it! A succesful submission. According to the designs there's only one thing left to do here:

  • Add another form submission.

Easy peasy, we just transition back to the initial form!

const formMachine = Machine(
  {
    initial: 'editing',
    context: {
      values: {},
      errors: {},
    },
    states: {
      editing: {
        initial: 'pristine',
        on: {
          CHANGE: {
            actions: ['onChange'],
          },
          SUBMIT: 'submitting',
        },
        states: {
          pristine: {
            // This is up to you, but I felt like the form needed to be cleared before receiving a new submission
            entry: ['clearForm'],
          },
          error: {},
        },
      },
      submitting: {
        invoke: {
          src: 'onSubmit',
          onDone: 'success',
          onError: {
            target: 'editing.error',
            actions: ['onError'],
          },
        },
      },
      success: {
        on: {
          AGAIN: 'editing',
        },
      },
    },
  },
  {
    actions: {
      onChange: assign({
        values: (ctx, e) => ({
          ...ctx.values,
          [e.key]: e.value,
        }),
      }),
      clearForm: assign({
        values: {},
        errors: {},
      }),
      onError: assign({
        errors: (_ctx, e) => e.data,
      }),
    },
  },
)

And that's it! A basic generic state machine that you could use on "any" form using any validation library or method that you want.

Checkout the interactive visualization here

Form State Machine

For the full machine code and an implementation in React using @xstate/react, check out this CodeSandbox

UI is implemented using the awesome Chakra UI

0
Subscribe to my newsletter

Read articles from Robert directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Robert
Robert