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}`
);
},
},
}
);
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>
);
}
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,
});
}}
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]);
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,
},
});
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,
});
}}
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.
Top comments (2)
Thanks for sharing your thoughts. I also lean towards using react-hook-form for managing form state, primarily for tasks like validation, while utilizing the state machine for other specific tasks, such as controlling the editing and submission behavior. Regarding your concern about keeping the state synchronized upon submission, the reset utility from useForm() can be quite handy. Additionally, to maintain cleaner code, you might consider replacing individual send() calls in the onChange handlers of each field with the watch utility from useForm(). This, combined with useEffect, can efficiently monitor overall form changes.
Your article is well-composed and effectively conveys the concept. These are just a few suggestions I have. 😊
Thank you for your comment! I like the idea of involving the
watch
method in the flow, might try it in another blog post.