Aug 8, 2023
TypeScript, react-hook-form, zod: blissful forms
How Aiven open source engineers create forms to create reliable, composable and delightful web forms
A few months back, Klaw started a rewrite of its front-end, migrating from Angular 1 to React. It has been an exhilarating time, not least of which because there is such a wide variety of great tools to use in the front-end world of today. One of the first decisions we had to make was: what do we do about forms?
It’s all about forms
Klaw is a governance tool for Apache Kafka® topics. Its objective is to provide a safety layer around all the operations on your clusters, by applying the four eyes principle: any operation must be approved by at least two people. So, instead of directly firing an operation, your workflow becomes about creating a request for the operation, and then relying on other members of your team to review and approve it before it is enacted. A little like working with source control and code review!
Klaw is therefore about creating requests. Requests to create, delete and promote topics, connectors, schemas and subscriptions. This means that Klaw has many forms, one for each type of operation a user may wish to enact, all of them tailored to make the user’s life easier by implementing strong validation. And because Klaw is an open source project, all of them need to be extensible for future features and all of them need to be easily understandable for anyone wanting to contribute.
We therefore need tools to create forms which are:
- Reliable: as the driver for the core features of Klaw, we need strict type safety and solid validation capabilities.
- Composable: because of the complexity of some of our forms, we need simple building blocks allowing us to build freely.
- Delightful: users and developers will spend a lot of time with our forms. It better be a good time!
Thankfully, because the new Klaw front-end uses TypeScript, we have access to a powerful duo: zod
and react-hook-form
.
zod
?
What is zod
is a is a "TypeScript-first schema declaration and validation library."
zod
is a library of primitives that allow developers to construct a representation of an object (a “schema”), and infer TypeScript types from it. It also provides tools to validate the values stored by a given object against such a schema.
It has many applications, but it is especially well-suited for form validation, and the creators of react-hook-form
are well aware of it.
react-hook-form
?
What is react-hook-form
provides "performant, flexible and extensible forms with easy-to-use validation."
react-hook-form
is a form library that defaults to the uncontrolled components approach and follows HTML standards for form creation. Using react-hook-form
, forms are accessible because of adherence to the HTML standard, and the library provides a familiar API. It is however very permissive, with a flexible and extensible hooks toolbox, including support for controlled inputs. It allows developers to use its APIs in any way they see fit, with strong TypeScript support.
This is significant for us in many ways:
- It makes it easy to write composable building blocks for our forms
- We can freely choose if input components need to be controlled or uncontrolled
- And most importantly, we can use
zod
as our validation tool by leveraging the resolver API.
How we use them
The Klaw team decided to split the responsibilities of react-hook-form
and zod
into different files, and bring them together when we want to render a form.
react-hook-form
: exports building blocks to Form.tsx
Form.tsx
defines and exports all the necessary blocks needed to build a form with react-hook-form
, with the integration of zod
for validation taken into account. We use this approach internally when building the Aiven Console, and implemented it in Klaw as well for the express purpose of encapsulating patterns which may otherwise lead to inconsistencies. For example, when should the validation run: should it be on change, on touch, on blur? This decision is made once in Form.tsx
, and applies to all forms in the application. This opinionated approach allows for a straightforward developer experience by providing a single way to build a form.
useForm
The blueprints: useForm
is how we define which values our form holds, and how we validate those values. We choose to wrap react-hook-form
’s native useForm
in our own custom hook. This allows us to provide more meaningful naming for its different parameters (e.g. schema instead of resolver, for example), and to set default values we want shared across all forms (e.g. validation onTouched
). It returns an object holding the values and methods we will need for form handling and validation, typed according to the schema we passed it.
Form
The foundations: Form
holds our form’s DOM structure. It is a simple component returning an HTML form element wrapped in react-hook-form
’s FormProvider
. The FormProvider
will receive the values from the object returned from useForm
as props.
The bricks: input components
We define a variety of input components to express all the different kinds of values and user experiences we wish for in our forms. They will be rendered as children to a Form component. They are all registered to their parent’s form by calling the useFormContext
hook, and using its return value to register themselves.
- Simple inputs are uncontrolled. They are registered to their parent form by calling
register
method with theirname
. - More complex inputs are wrapped in a
react-hook-form
Controller
component. They are registered to their parent by passing thecontrol
value from the form context to theController
as thecontrol
prop.
zod
: defining a safety net with schemas and validation
Because we use zod
to define the shape, the types and the validation of each of our forms, we co-locate those as close to the relevant form is defined, usually in a form-schemas
folder. TopicSchema.tsx
renders the form, using the schema and type defined in topic-schema-request-form.ts
.
The schema and its inferred type
Our form schemas usually are objects holding a series of key-value pairs. Each key is the name of the form field, and each value is its type., zod
allows us to infer a TypeScript type from this schema with the infer
method.
Validation
Validation is done as an integral part of the schema definition. It is extremely flexible, and can be done in different ways, depending on the rules needed and their complexity.
For simple validation, zod
provides a series of methods, , like .min
, .max
, .optional
, .required
, which can be appended to each schema value. They always take a second parameter to specify the error message which should be dispatched.
For more complex cases for example when a value relies on knowing the value of another field, zod
provides the refine
and superRefine
methods. These methods take a function as an argument, and the function can contain custom logic to handle these specific cases. For example, we can ensure that the replicationFactor
value can never be higher than the maxReplicatioFactor
value when creating a Topic request in Klaw. We then use addIssue
to craft the exact kind of error we want to dispatch in this case.
Together at last: rendering a form
We now have:
Form and input components we can freely compose
Form schemas, type and validation
And it’s time to use them together to render the UI we want for our users! This is how we do it:
- Import
useForm
,Form
, and the necessary inputs fromForm.tsx
- Import the schema and the inferred type from where they are defined
- Call
useForm
with the inferred type as a generic type argument, and pass it the schema (and optional default values) - Pass the returned values of
useForm
as props to theForm
component - Render the desired inputs as children of
Form
, passing them thename
prop corresponding to the correct field in the form schema.
Conceptually, it looks like this:
react-hook-form zod react-hook-form + zod // NameForm.tsx import { useForm, Form, TextInput } from "src/app/components/Form" import { formSchema, FormSchema } from "src/app/components/name-form.ts" // … const form = useForm<FormSchema>({ schema: formSchema, defaultValues, }); <Form {...form} > <TextInput<FormSchema> name={'name-in-schema-type'} /> -> only names in FormSchema allowed as name for TextInput </Form>
We can now rest easy, knowing that we have achieved our objectives. Our forms are:
- Reliable: they are typed end to end, with robust validation
- Composable: building a form is entirely about fitting our building blocks together
- Delightful: being able to create form with confidence and speed is a perpetual delight, as is the speed of delivery it allows our team!
Shortcomings
But of course, every single tech choice has trade-offs, and this is no exception.
Mixing uncontrolled and controlled components:
Many of our more complex input components need to be controlled (by wrapping in a Controller
, or by setting their value
prop explicitly). This goes against the “HTML standard” core philosophy of the library, and adds complexity. This complexity naturally leads to bugs, such as this one.
A little bit of boilerplate:
Adding a new input type to Form.tsx
, writing schemas and validation rules for every form, remembering to use the proper syntax for typing precisely every input, this all may feel tedious. You may yearn for the sweet pleasure of colocation, as opposed to the strong separation of concern this approach enforces. And you may be right! This is not a miracle solution, only a solution which may or may not fit your purpose.
There is no escaping complex UIs:
As simple as our approaches and building blocks are, the UIs we build are sometimes unavoidably complex. This is the case for our ACL forms, which are extremely dynamic, with a varying amount of fields, which may or may not be enabled, depending on a large number of factors. This leads to some interesting schemas and validation, a host of custom fields specifically designed for these forms, and a test file that is 3000+ lines long.
Contribute to Klaw
Fortunately, even if we can’t escape those shortcomings, we can mitigate it by iterating, and improving! Pick up the refactoring issue if you want to contribute! We welcome every PR, so please take a look at our good first issues if your interest has been piqued by this post. Be sure to look over our contribution guidelines beforehand!
Stay updated with Aiven
Subscribe for the latest news and insights on open source, Aiven offerings, and more.