HTML Form Validation is heavily underused
Attributes, methods, and properties
It’s easy to disallow empty inputs by adding
a required
attribute:
Beyond that, there is a bunch of other ways that you can add constraints to your input. Precisely, there are three ways to do it:
- Using specific
type
attribute values, such as"email"
,"number"
, or"url"
- Using other input attributes that create constraints, such as
"pattern"
or"maxlength"
- Using the
setCustomValidity
DOM method of the input
The last one is the most powerful as it allows to create arbitrary validation logic and handle
complex cases.
Do you notice how it differs from the first two techniques? The first two are defined with attributes, but setCustomValidity
is a method.
Here’s a great write-up that explains the differences between DOM attributes and properties: https://jakearchibald.com/2024/attributes-vs-properties/
The nuance of an imperative API
The fact that setCustomValidity
API is exposed only as a method and doesn’t have an attribute equivalent
leads to some terrible ergonomics. I’ll show you with an example.
But first, a very quick intro to how this API works:
This would make input invalid and the browser will show the reason as “Any text message”.
Passing an empty string makes the input valid (unless other constraints are applied).
That’s pretty much it! Now let’s apply this knowledge.
Let’s say we want to implement an equivalent of the required
attribute.
That means that an empty input must be prevent the form from being submitted.
This kind of looks like we’re done and this code should be enough to accomplish the task. But try to see it in action:
It may seem to work, but there’s just one important edge case: the input is in a valid state initially. If you reset the component and press the “submit” button, the form submission will go through. But surely, before we ever touch the input, it is empty, and therefore must be invalid. But we only ever do something when the input value changes.
How can we fix this?
Let’s execute some code when the component mounts:
Great! Now everything works as expected. But at what cost?
The boilerplate problem
Let’s look at our clumsy way to validate the initial value:
Ugh! Wouldn’t want to write that one each time. Let’s think about what’s wrong with this.
- The validation logic is duplicated between the onChange handler and the initial render phase
- The initial validation is not co-located with the input, so we’re losing code cohesion. It’s fragile: if you update validation logic, you might forget to update code in both places.
- The
useRef
+useLayouEffect
+onChange
combo is just too much ceremony, especially when a form has a lot of inputs. And it gets even more confusing if only some of those inputs usecustomValidity
This is what happens when you deal with a purely imperative API in a declarative component.
Unlike validation attributes,
CustomValidity
is a purely imperative API. In other words, there’s no input attribute that we can use to set custom validity.
In fact, I would argue that this is the main reason for poor adoption of native form validation. If the API is cumbersome, sometimes it just does not matter how powerful it is.
The missing part
In essence, this is the attribute we need:
In a declarative framework, this would allow to define input validations in a very powerful way:
Pretty cool! In my opinion, at least. Though you can rightfully argue that this accomplishes only what
the existing required
attribute is already capable of. Where’s the “power”?
Let me show you, but first, since there’s no actual custom-validity
currently
in the HTML Spec,
let’s implement it in userland.
This will work well for our demo purposes.
For a production-ready component check out
a more complete implementation.
The power
Now we’ll explore which non-trivial cases this design can help solve.
In real-world apps, validation often gets more complex than local checks. Imagine a username input that should be valid only if the username is not taken. This would require async calls to your server and an intermediary state: the form should not be valid while the check is in progress. Let’s see how our abstraction can handle this.
Play around with this example. It uses the required
to prevent empty inputs. But then it relies on customValidity
to mark input as invalid during the loading state and based on the response.
Implementation
First, we create an async function to check whether the username is unique that imitates a server request with a delay.
Next, we’ll create a controlled form component and use react-query to manage to server request when the input value changes:
Great! We have the setup in place. It consists of two crucial parts:
- Verification request state managed by
useQuery
- Our custom
<Input />
component that is capable of taking thecustomValidity
prop
Let’s put those pieces together:
That’s it! We’re describing the whole async validation flow, including loading, error and success states, in one attribute. You can go back to see the result again if you wish
One more
This one will be shorter, but also interesting, because it covers dependent input fields. Let’s implement a form that requires to repeat the entered password:
You can try it out:
Conclusion
I hope I’ve been able to show you how setCustomValidity
can cover
validation needs of all kinds.
But the real power comes from great APIs.
And hopefully, you are now equipped with one of those.
And even more hopefully, we will see it natively in the HTML Spec one day.