Building a Generic State Machine for Form Handling Using XState - DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Β 
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:
notion imagenotion image
From this concept we can deduce four "states":
  1. Editing
  1. Submitting
  1. Error
  1. 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
notion imagenotion image
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