Aiven Blog

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 picture of Mathieu Anderson. He has brown hair and blue eyes and a beard.

Mathieu Anderson

|RSS Feed

Senior Software Engineer at Aiven

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.

What is zod?

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.

What is react-hook-form?

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.

The blueprints: useForm

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.

The foundations: Form

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.

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.

an animated gif of a code environment. The cursor rolls over an object, topicRequestSchema, and displays a modal window showing that the IDE can infer its type.

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.

a screenshot of a code environment showing the .min and .max methods from zod. Both are implemented with error messages

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.

an image of a code environment showing a more complex validation case with zod's refine and superRefine methods. Calling superRefine requires that you define the refinement itself in another method, as described above.

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:

  1. Import useForm, Form, and the necessary inputs from Form.tsx
  2. Import the schema and the inferred type from where they are defined
  3. Call useForm with the inferred type as a generic type argument, and pass it the schema (and optional default values)
  4. Pass the returned values of useForm as props to the Form component
  5. Render the desired inputs as children of Form, passing them the name 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!


Related resources