⚔️ Code Conqueror

✏️ XState forms and validation

Mar 09, 2020

XState is a great solution for creating custom forms in a React based application that have strict validation and are stable against users hitting edge cases.

Writing forms in React can be tricky because there are a lot of edge cases, between multiple submissions, invalid submissions, and server errors. There are a whole lot of different aspects to manage even for a simple form. XState is a great way to organize forms in your React applications and make them super robust.

Why are forms hard?

Building forms on the web has been something we've done since the dawn of the web. Over the years the browser APIs have evolved and a lot of progress has been made, but at the same time there are still a lot of things to consider when building a form:

  • Errors (validation and server errors) need to be handled
  • Users need to not be allowed to submit multiple times
  • Users can't submit invalid form values
  • Users can't submit the same invalid values in sequence (eg. if failure, next submission needs to be different)

Even in a simple form It's not easy to handle all of these using React's built-in state management. Because of that a number of solutions like Formik have popped up to help build more robust / standardized forms.

How can XState help?

XState is a library that makes using state machines in React super simple. It only adds some minimal boiler plate to a solution like

useReducer
but can add a lot of stability to your React application. Our form will be comprised of four states:

  • editing
  • submitting
  • failed
  • success

The data for the form values will be stored in the machine context. You can see in the above graphic that you can go from editing only to submitting, which either resolves to success or failure depending on the server response. From failure you cannot submit again, but must change either the username or password in order to submit.

Here's the skeleton of our state machine:

const loginMachine = Machine(
{
id: "login",
initial: "editing",
context: {
username: "",
password: "",
error: null,
},
states: {
editing: {
on: {
CHANGE_USERNAME: {
actions: "changeUsername",
},
CHANGE_PASSWORD: {
actions: "changePassword",
},
SUBMIT: "submitting",
},
},
submitting: {
invoke: {
src: "submit",
onDone: {
target: "success",
},
onError: {
target: "failure",
actions: "setError",
},
},
},
success: {
type: "final",
},
failure: {
on: {
CHANGE_USERNAME: {
target: "editing",
actions: ["changeUsername", "clearError"],
},
CHANGE_PASSWORD: {
target: "editing",
actions: ["changePassword", "clearError"],
},
},
},
},
},
{
actions: {
changeUsername: assign({
username: (_ctx, evt) => evt.value,
}),
changePassword: assign({
password: (_ctx, evt) => evt.value,
}),
setError: assign({
error: (_ctx, evt) => evt.data,
}),
clearError: assign({
error: (_ctx, _evt) => null,
}),
},
services: {
submit: () => {},
},
}
);

You can see that the

changeUsername
and
changePassword
actions simply update the username and password context. The
setError
and
clearError
are used to update the error information that's displayed when we are in the failure state.

Handling form validation with guards

The first thing we can do to improve this machine is add some guards to prevent users submitting invalid usernames / passwords. Say usernames are always email addresses, we can update the event handlers for the

CHANGE_USERNAME
event to guard against bad username values:

const usernameIsEmail = (_ctx, event) => {
const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return emailRegex.test(String(event.value).toLowerCase());
};
const loginMachine = Machine({
id: "login",
initial: "editing",
context: {
username: "",
password: "",
error: null,
},
states: {
editing: {
on: {
CHANGE_USERNAME: [
{
actions: "changeUsername",
cond: usernameIsEmail,
},
{
target: "failure",
actions: "setEmailFailError",
},
],
},
},
},
});

Here we have a regex that validates the email is indeed an email. If the typed username is not an email address we update the error to show the user that they need to type an email address in this field. Because we transition to the failure state in that case we won't be allowed to submit the form until we fix this (prevents bad request from even reaching the backend).

We could do something similar with the password to validate that the password matches the requirements for passwords on our site.

Handling submission with a service

The next thing we need to implement is the handler for submitting the form. In XState the way to handle these kind of asynchronous processes that may result in a different state transition is invoking a service. Here you can see that we invoike the

submit
service when we transition to the submitting state.

services: {
submit: (_ctx, _evt) =>
new Promise(async (resolve, reject) => {
await sleep(2000);
const rand = Math.random();
if (rand < 0.5) {
reject("failed to log in");
} else {
resolve("user secret data");
}
});
}

For our example we return a promise from the service that sleeps for two seconds and then randomly either fails or succeeds. In the machine you can see that in the error case we transition to the failure state, thus preventing more submissions until the inpus are changed and in the success state we are in a 'final' state and therefore cannot submit again or transition (eg at this point the app should remove the fields or do a route transition etc.)