Building a Generic State Machine for Form Handling Using XState
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:
From this concept we can deduce four "states":
- Editing
- Submitting
- Error
- 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
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
Subscribe to my newsletter
Read articles from Robert directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by