The official React documentation suggests 3 possible ways to handle form submission/validation:
But none of these 3 methods are particularly appealing to me.
I personally don’t like controlled components as it involves manual state management that, most of the time, leads to unneeded and inefficient re-renderings.
From the official docs:
“When using controlled components you need to write an event handler for every way your data can change and pipe all of the input state through the React component”.
Controlled components also require you to write, test, and maintain all the validation logic. Adding/removing a controlled component to a form is a time-consuming and error-prone task as it normally requires touching all the form validation and submission logic.
React docs suggest implementing uncontrolled components using a ref to get form values from the DOM but then don’t provide much info on what the best practices are to extract the data and validate it.
Either way, we are left with the nontrivial task of implementing the logic to validate and collect the form data.
These days there are plenty of 3rd party libraries that do exactly that... but do we really need one?
Let’s build a simple form like this one:
An interactive demo is available here.
We want to build a form implementing the following requirements:
Name and Email are mandatory
Email should represent a valid email address
The address is optional, no constraints
Tel is optional but if entered it should represent a formally correct UK phone number
On submission the form gets validated and should show validation errors like this:
In case of validation errors, the focus should be moved to the first invalid field.
(Source MDN): HTML5 already has the ability to validate most user data without relying on JavaScript. This is done by using validation attributes on form elements.
required
: Specifies whether a form field needs to be filled in before the form can be submitted.
minlength
and maxlength
: Specifies the minimum and maximum length of textual data (strings).
min
and max
: Specifies the minimum and maximum values of numerical input types.
type
: Specifies whether the data needs to be a number, an email address, or some other specific preset type.
pattern
: Specifies a regular expression that defines a pattern the entered data needs to follow.
If the data entered in a form field follows all of the rules specified by the above attributes, it is considered valid. If not, it is considered invalid.
Most browsers support the Constraint Validation API, which consists of a set of methods and properties that enable checking values that users have entered into form controls, before submitting the values to the server.
With these tools at our disposal, we can build custom components for easy and efficient form management.
The building blocks of our solution are the following two custom components:
The key attribute here is noValidate.
The novalidate
attribute turns off the browser's automatic validation error messages. Without that our form would look like this:
All the magic happens in the handleSubmit callback:
we stop the form submission with e.preventDefault()
we make a note of the form validity state with isValid
we add the “submitted” className to improve the UX (more on this in a moment)
we move the focus to the first invalid field if any
we collect the form data and call the onSubmit callback, if valid
The last one is another key point of this solution: collecting the form data using the FormData object. There is no need to manually go through the form elements and extract their values.
“The
FormData
interface provides a way to easily construct a set of key/value pairs representing form fields and their values” —( source: MDN)
The TextInput component is responsible for rendering the input field, the label, and the possible validation error.
You can see the
, but essentially this component sets an internal state variable with the validation error string coming from the constraint API and resets it on blur if the error has been fixed:
The code to implement the form in the example looks like this:
Things to notice:
Whenever we need to add another field to the form we just add a new TextInput and we set the relevant attributes to it.
No need to update schemas or add yet another useState to track the value of the new field, let’s just offload the heavy lifting to the built-in API!
The final touch to improve the UX is to show validation errors only after the form gets submitted.
Using just the :invalid
pseudo-class in our CSS to style incorrect fields would cause red highlighted input boxes and error messages to appear as soon as the page loads… and we don’t want to scream in the face of a user that his/her input is incorrect before they even have the chance to type a character in!
For this reason, we add the .submitted className to the form and style the TextInput with this:
One great thing to keep in mind is that the constraint API validation messages are localized by default — i.e. they come in the locale the user OS is set to!
If you are happy with the default messages then there is nothing else you need to worry about: your English, Spanish, Italian or Chinese users will get the messages in their own language.
The TextInput
component provided in the example allows also for basic validation message customization via the errorText
attribute:
It is just an example to show how one could customize those messages.
Once the form is formally correct we can actually submit the form in the onSubmit callback.
We receive a FormData object which can then be easily sent using the fetch()
or XMLHttpRequest.send()
method. It uses the same format a form would use if the encoding type were set to "multipart/form-data"
, or we can transform it into a more familiar key/value object:
to produce the following:
There is beauty in simplicity and form validation/submission doesn’t get much simpler than this IMHO 🙂
Any comment/feedback is much appreciated!
Link to part two: a not-so-trivial example.
Also published here.