Form validation with xState and react-hook-form

gtodorov

Georgi Todorov

Posted on April 7, 2023

Form validation with xState and react-hook-form

TLDR

If you are curious about the full-working example, it is here.

Background

The last couple of projects I worked on, our team had the resources to create its own form validation system. We had custom React hook, xState machines and all validating methods written ourselves. However, my current project has a smaller team and we rely more on established open source solutions.
As xState is our state management tool of choice, I conducted some quick research to find a compatible validation library. I stumbled upon this great video and decided that will give react-hook-form a go for our forms validation.

Use case

To demonstrate the process, we will introduce a simple form that consists of just two required fields and a submit button. To provide a more realistic example, we will pass an xState actor that will be responsible for controlling the form on the respective page.

Initial implementation

I always prefer to use controlled inputs whenever possible. I find it more comfortable when the field value is stored and ready to react to any external event.

Machine

This means that we now need a machine that will take care of the form operations.

export const formMachine = createMachine(
  {
    id: "formMachine",
    context: { form: { firstName: "", lastName: "" } },
    initial: `editing`,
    states: {
      editing: {
        on: {
          SET_FORM_INPUT_VALUE: { actions: ["setFormInputValue"] },
          SUBMIT_FORM: { target: "submitting" },
        },
      },
      submitting: {
        invoke: {
          src: "submitForm",
          onDone: { actions: ["clearFields"], target: "editing" },
        },
      },
    },
  },
  {
    actions: {
      setFormInputValue: assign((context, event) => {
        return {
          ...context,
          form: {
            ...context.form,
            [event.key]: event.value,
          },
        };
      }),
      clearFields: assign((context, event) => {
        return { form: { firstName: "", lastName: "" } };
      }),
    },
    services: {
      async submitForm(context, event) {
        // Imagine something asynchronous here
        alert(
          `First name: ${context.form.firstName} Last name: ${context.form.lastName}`
        );
      },
    },
  }
);
Enter fullscreen mode Exit fullscreen mode

You may find this self-explanatory but let's have a few words about the code above. The machine represents a simple form with two fields, and two states, editing and submitting.

When the form is in the editing state, it can receive two types of events: SET_FORM_INPUT_VALUE, which is used to update the value of a field, and SUBMIT_FORM, which is used to trigger the form submission.

When the SET_FORM_INPUT_VALUE event is received, the setFormInputValue action is executed, which updates the value of the specified field in the form context. When the SUBMIT_FORM event is received, the form transitions to the submitting state.

When the form is in the submitting state, it invokes a service called submitForm, which represents the asynchronous submission of the form data to a backend system. When the service is done, it triggers the clearFields action and transitions back to the editing state.

Form

Now we can work on the page that will be displaying our form.

type FormData = {
  firstName: string;
  lastName: string;
};

export default function DefaultValuesExample() {
  const [state, send] = FormMachineReactContext.useActor();

  const {
    handleSubmit,
    formState: { errors },
    control,
  } = useForm<FormData>({
    defaultValues: { firstName: "", lastName: "" },
  });

  return (
    <form
      onSubmit={handleSubmit((data) => {
        send({ type: "SUBMIT_FORM" });
      })}
    >
      <Controller
        control={control}
        name="firstName"
        rules={{
          required: { value: true, message: "First name is required." },
        }}
        render={({ field: { onChange, value } }) => {
          return (
            <input
              placeholder="First name"
              onChange={({ currentTarget: { value } }) => {
                onChange(value);
                send({
                  type: "SET_FORM_INPUT_VALUE",
                  key: "firstName",
                  value: value,
                });
              }}
              value={value}
            />
          );
        }}
      />
      {errors.firstName && <span>This field is required</span>}

      <Controller
        control={control}
        name="lastName"
        rules={{
          required: { value: true, message: "Las name is required." },
        }}
        render={({ field: { onChange, value } }) => {
          return (
            <input
              placeholder="Last name"
              onChange={({ currentTarget: { value } }) => {
                onChange(value);
                send({
                  type: "SET_FORM_INPUT_VALUE",
                  key: "lastName",
                  value,
                });
              }}
              value={value}
            />
          );
        }}
      />
      {errors.lastName && <span>This field is required</span>}

      <input type="submit" />
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

The first step is to use the new xState utility createActorContext to obtain access to the form machine actor on our page.

Next, we set up the useForm hook from the react-hook-form library, which is its flagship feature. It helps us manage the input state and validation. It returns an errors object that we can use to display any errors if the validation rules are not met, as well as a handleSubmit function that is responsible for targeting the submitting state of the form machine. For now, we only pass the default values of the firstName and lastName fields.

Since we have decided to work with controlled inputs, using the Controller component will give us the most benefit from the library.

Each field is rendered using the Controller component, which integrates the react-hook-form library with the state machine. The name prop of the Controller component specifies the name of the field in the form data object, and the rules prop specifies the validation rules for the field.

Downsides

We now have a functional form with validation. While react-hook-form is definitely a developer-friendly solution that saves a lot of work, I still have a couple of concerns.

onChange={({ currentTarget: { value } }) => {
  onChange(value);
  send({
    type: "SET_FORM_INPUT_VALUE",
    key: "lastName",
    value,
  });
}}
Enter fullscreen mode Exit fullscreen mode

As advised in the react-hook-form documentation, we must update both the validation state and the input state to ensure their values remain in sync. However, in my experience, this process can be error-prone.

To illustrate this point, I just need to run the application. and enter values into the form fields. After clicking the submit button, the correct values are displayed in the alert modal, and the context is cleared using the clearFields action. However, the inputs still hold the values that we've typed in. It appears that the react-hook-form still keeps its state internally, which is to be expected. Therefore, we must sync the state again to ensure consistency.

Since we need to sync the input values with the form state, we can use an useEffect hook that depends on the form machine's state value. If the state is submitting, we can assume that it's safe to clear the inputs using the setValue method provided by react-hook-form.

const [state, send] = FormMachineReactContext.useActor();

const {
  handleSubmit,
  formState: { errors },
  control,
  setValue,
} = useForm<FormData>({
  defaultValues: { firstName: "", lastName: "" },
});

useEffect(() => {
  if (state.matches("submitting")) {
    setValue("firstName", "");
    setValue("lastName", "");
  }
}, [state.value]);
Enter fullscreen mode Exit fullscreen mode

Improved implementation

Although the validation library integration was quick and reliable, there were some downsides. However, in recent releases of react-hook-form, a new option called values has been added to the useForm hook:

The values props will react to changes and update the form values, which is useful when your form needs to be updated by external state or server data.

To integrate it in our example we simply need to pass the machine context values to the values prop.

To integrate this new values option into our example, we can simply pass the machine context values to the values prop. Here's the updated code:

const [state, send] = FormMachineReactContext.useActor();

const {
  handleSubmit,
  formState: { errors },
  control,
} = useForm<FormData>({
  values: {
    firstName: state.context.form.firstName,
    lastName: state.context.form.lastName,
  },
});
Enter fullscreen mode Exit fullscreen mode

I find the defaultValues prop to be unnecessary for my case, as my form context already has initial values that are directly consumed by the input component.

Additionally, We can eliminate the useEffect because any updates to our machine context are automatically reflected in the form component, without the need to synchronise values.

Lastly, our onChange event handler from the Controller component is now much cleaner and safer:

onChange={({ currentTarget: { value } }) => {
  // no need to update the input values explicitly
  send({
    type: "SET_FORM_INPUT_VALUE",
    key: "firstName",
    value: value,
  });
}}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Both xState and react-hook-form are highly flexible and adaptable, making them suitable for various form validation cases. With xState, you can define and manage the state of your application in a clear and structured way, while react-hook-form provides an easy and efficient way to manage form state and validation. Together, they can streamline your form development process and provide a solid foundation for building reliable and scalable forms.

💖 💪 🙅 🚩
gtodorov
Georgi Todorov

Posted on April 7, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related