DEV Community

Cover image for Building type-safe forms in React with react-ts-form
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Building type-safe forms in React with react-ts-form

Written by Antonello Zanini✏️

Building forms with React can often be cumbersome and time-consuming, requiring repetitive code to handle form state, user input, and data validation.

Although several form libraries exist to simplify the process of building forms in React, not all have built-in support for type-safe forms. Thus, even when using a form library like Formik, creating type-safe forms in TypeScript can result in boilerplate code.

One solution to this problem is react-ts-form, a complete and customizable library for creating type-safe forms in React. Thanks to this library, you can avoid boilerplate code and make type-safe form development in React more maintainable.

In this article, you will learn what react-ts-form is, what its most important features are, and how to use it to build a type-safe React form in TypeScript. Jump ahead:

Check out the complete GitHub repository for the example type-safe form we’ll create later in this article using react-ts-form.

Dealing with forms in React

The boring and time-consuming task of building forms in React generally involves the following steps:

  1. Defining form components and input fields
  2. Managing form state for input values and validation errors
  3. Monitoring user input and updating the status accordingly
  4. Creating validation functions and applying them to form fields
  5. Handling form submission

Each of these steps needs to be implemented manually. So, dealing with several forms can become particularly tedious. And as we mentioned earlier, this task generally results in cumbersome, boilerplate, and repetitive code.

As a result, several libraries have been developed to make it easier to create and manage forms. The most popular one is Formik, an open source library that makes it easier to deal with form state, handle user input, implement validation, and manage form submission.

Formik drastically reduces the amount of boilerplate code required to create forms in React. However, its main problem is that even though it is written in TypeScript, it does not offer built-in support for type-safe forms.

You can manually integrate Formik with schema validation libraries like Zod and Yup, but that will involve boilerplate code. As a result, this library may not be the best solution for building type-safe forms with TypeScript in React.

So, is there an alternative to Formik for type-safe forms? Yes, and it is called react-ts-form!

How react-ts-form makes building type-safe forms possible in React

The lightweight react-ts-form library leverages Zod's powerful data validation capabilities and extends React Hook Form with additional developer-friendly features.

In other words, this form library allows you to define form type schemas using Zod's syntax, including field validation and description. Plus, it integrates with React Hook Form to handle type-safe form state management, user input tracking, and form submission.

Compared to other form libraries like Formik, react-ts-form provides a more concise and streamlined syntax. This makes it easier to define and manage form schemas while avoiding boilerplate code.

That does not mean that react-ts-form sacrifices customizability. On the contrary, it supports custom validation rules, types, and input components. It also offers a flexible and extensible error-handling system, allowing developers to customize error messages and display them as they want.

Pros and cons of react-ts-form

As opposed to Formik, react-ts-form comes with advanced type schema validation capabilities. In particular, it equips you with everything you need to build a type-safe form in just a few lines of code.

Some pros of the react-ts-form library include:

  • Automatically generates type-safe forms with zod schemas
  • Drastically reduces boilerplate code
  • Type checks even on input component props
  • Headless UI that supports any custom input React component
  • Provides special quality-of-life features not available in vanilla Zod or React Hook Form
  • Lightweight — only about 3KB gzipped

However, you should also keep the following considerations in mind:

  • Does not support class components
  • Layout capabilities are still limited

Time to see react-ts-form in action!

Creating an example type-safe form with TypeScript in React

Let’s see what react-ts-form has to offer when it comes to building type-safe forms in React through some examples. Here's what we'll cover in this section:

The complete code for this example type-safe form is available on GitHub. You can check it out to follow along as we get started.

Prerequisites to getting started with react-ts-form

To get the most value out of react-ts-form, you need the following:

  • A basic understanding of schema validation in TypeScript with Zod
  • Basic knowledge regarding how to use React Hook Form
  • A properly configured TypeScript React project
    • Set the strict field in compilerOptions to true in your tsconfig.json file to enable stricter type checking
    • Verify that your TypeScript is v4.9 or newer
  • Add @ts-react/form to your project’s dependencies
  • Install necessary peer dependencies — zod, react-hook-form, and @hookform/resolvers

To meet the last two prerequisites, install the library and peer dependencies with the following command:

npm install @ts-react/form
npm install zod react-hook-form @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode

You are now ready to create a type-safe form in React!

Defining a type-safe form component

First, you need to define a type-to-component mapping object to map Zod primitives to your input components. Then, you can use that object to create a global form component with createTsForm():

// src/components/MyTypeSafeForm.tsx

import { createTsForm } from "@ts-react/form"
import { z } from "zod"
// import the custom input components
import TextField from "./TextField"
import NumberField from "./NumberField"
import CheckBoxField from "./CheckBoxField"
import DateField from "./DateField"

// specify the mapping between the zod types
// and your input components
const mapping = [
    [z.string(), TextField],
    [z.boolean(), CheckBoxField],
    [z.number(), NumberField],
    [z.date(), DateField],
] as const // <- note that this "as const" is necessary

// create the type-safe form React component
const MyTypeSafeForm = createTsForm(mapping)

export default MyTypeSafeForm
Enter fullscreen mode Exit fullscreen mode

Since the mapping between types and components is generally the same across the entire project, you typically need only one form component.

You can then use <MyTypeSafeForm> to define a type-safe form, as in the example below:

// src/components/LoginForm.tsx

import { z } from "zod"
import MyTypeSafeForm from "./MyTypeSafeForm"

const LoginSchema = z.object({
  email: z.string(), // will render a <TextField> component
  password: z.string(), // will render a <TextField> component
  keepSignedIn: z.boolean(), // will render a <CheckBoxField> component
})

export default function LoginForm() {
  function onSubmit(data: z.infer<typeof LoginSchema>) {
    // retrieve type-safe data when the form is submitted
    // and call the login API...
    const requestBody = {
      email: data.email, // string
      password: data.password, //string
      keepSignedIn: data.keepSignedIn, //boolean
    }

    fetch("https://api.example.com/login", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(requestBody),
    })
      .then((response) => {
        // ...
      })
      .catch((error) => {
        // ...
      })
  }

  return (
    <MyTypeSafeForm
      schema={LoginSchema}
      onSubmit={onSubmit}
      // add the Submit button to the form
      renderAfter={() => <button type="submit">Login</button>}
      defaultValues={{
          keepSignedIn: true
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

As you can see, <MyTypeSafeForm> accepts a schema object containing the form fields. react-ts-form renders each field as the input component specified in the mapping object passed to createTsForm().

In other words, the library will initialize each field with the values defined in the optional defaultValues prop. When a user clicks on the type="submit" button, it will then pass the typed form values to the onSubmit() callback.

Note that if your Zod schema involves a type that does not have a mapping, react-ts-form will fail with the following error:

No matching zod schema for type `<ZodType>` found in mapping for property `<your_property>`. Make sure there's a matching zod schema for every property in your schema.
Enter fullscreen mode Exit fullscreen mode

Another important aspect to take into account is that all fields specified in the schema are required by default. To make them optional, you can use the Zod's optional() function:

const LoginSchema = z.object({
  email: z.string(), 
  password: z.string(), 
  keepSignedIn: z.boolean().optional(), // optional field
})
Enter fullscreen mode Exit fullscreen mode

In the onSubmit callback, keepSignedIn will now have the type boolean | undefined.

Also, react-ts-form supports labels and placeholders through Zod's describe() method:

const LoginSchema = z.object({
  // label: "Email", placeholder: "email@example.com"
  email: z.string().describe("Email // email@example.com"), 
  // label: "Password" and no placeholder
  password: z.string().describe("Password"), 
  keepSignedIn: z.boolean().optional(),
})
Enter fullscreen mode Exit fullscreen mode

Keep in mind that labels and placeholders must be specified in the following format:

<LABEL> // <PLACEHOLDER>
Enter fullscreen mode Exit fullscreen mode

Handling type collisions

In the example above, the email and password fields are both rendered with the generic <TextField> component. This happens because they are both of type string. However, the password field should require a specialized input component.

For this reason, react-ts-form supports type collisions through the createUniqueFieldSchema() function. Use it to map the same zod schema type to different components, as below:

// src/components/MyTypeSafeForm.tsx

import { createTsForm, createUniqueFieldSchema } from "@ts-react/form"
import { z } from "zod"
import TextField from "./TextField"
import NumberField from "./NumberField"
import PasswordField from "./PasswordField"

export const PasswordSchema = createUniqueFieldSchema(
    z.string(),
    "password" // a string ID that must be unique across all zod schema types
)

const mapping = [
    [z.string(), TextField],
    [PasswordSchema, PasswordField], // special mapping
    [z.boolean(), CheckBoxField],
    [z.number(), NumberField],
    [z.date(), DateField],
] as const

// MyTypeSafeForm...
Enter fullscreen mode Exit fullscreen mode

You can then use the new zod type in z.object() while creating a schema:

// src/components/LoginForm.tsx

import { z } from "zod"
import MyTypeSafeForm, { PasswordSchema } from "./MyTypeSafeForm"

const LoginSchema = z.object({
    email: z.string(), // will render a <TextField> component
    password: PasswordSchema, // will render a <PasswordField> component
    keepSignedIn: z.boolean(), // will render a <CheckBoxField> component
}) 

// LoginForm...
Enter fullscreen mode Exit fullscreen mode

The password field will now be rendered in a <PasswordField> input component.

Creating input components

As explained earlier, the zod-to-component mapping object associates zod schema types to input components. These cannot be just any components — they must follow a particular logic. More specifically, they must rely on the useTsController() hook:

// src/components/TextField.tsx

import { useDescription, useTsController } from "@ts-react/form"

export default function TextField() {
    const { field } = useTsController<string>()
    // to render the label and placeholder
    const { label, placeholder } = useDescription()

    return (
        <>
            <label>{label}</label>
            <input
                placeholder={placeholder}
                value={field.value ? field.value : ""} 
                onChange={(e) => {
                    // to update the form field associated to this
                    // input component 
                    field.onChange(e.target.value)
                }}
            />
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

useTsController() is a typed extension of React Hook Form’s useController() hook, responsible for keeping the form state up-to-date. Also, note the use of the useDescription() hook to retrieve the field label and placeholder.

react-ts-form is also aware of the input component props. Suppose that your <TextField> component requires a bordered boolean:

// src/components/TextField.tsx

import { useDescription, useTsController } from "@ts-react/form"

export default function TextField({ bordered }: { bordered: boolean }) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

<MyTypeSafeForm> in <LoginForm> will now throw a type error: Popup Info Panel On Hover Showing Type Error For Mytypesafeform In Loginform This is because you must specify the bordered prop on all <TextField> fields. Thanks to the type-handling capabilities of react-ts-form, your IDE will offer autocomplete functionality for the input component props: Demo Of Autocomplete Functionality For Input Component Props Update your form as follows to make the type error disappear:

<MyTypeSafeForm
    schema={LoginSchema}
    onSubmit={onSubmit}
    // add the Submit button to the form
    renderAfter={() => <button type="submit">Login</button>}
    defaultValues={{
        keepSignedIn: true
    }}
    props={{
        email: {
            bordered: false
        }
    }}
/>
Enter fullscreen mode Exit fullscreen mode

If you do not know how to implement the most common input components, the react-ts-form docs provide sample implementations you can reference for help.

Adding non-input components

Your form may also involve non-input UI components, such as a header. You can specify them in react-ts-form with the renderBefore and renderAfter props. Similarly, you can use the beforeElement and afterElement to add UI elements between input components.

See those props in action in the following example:

<MyTypeSafeForm
    schema={LoginSchema}
    onSubmit={onSubmit}
    // add the Submit button to the form
    renderAfter={() => <button type="submit">Login</button>}
    // add a header section to the form
    renderBefore={() => <h1>Login</h1>}
    defaultValues={{
        keepSignedIn: true
    }}
    props={{
        password: {
            // separate the form input components with a line
            afterElement: <hr />
        }
    }}
/>
Enter fullscreen mode Exit fullscreen mode

renderAfter is typically used to add a “Submit” button to the form. When clicked, it will automatically trigger the onSubmit callback. Other UI components are optional and can be added as you see fit.

Validation and error handling

Another crucial aspect when it comes to building a form in React is data validation, which plays a key role in preventing server-side errors.

For example, some fields may be mandatory or accept only data in a specific format. Performing data validation on the frontend means not allowing users to submit the form until all values are valid.

To avoid making the validation process frustrating, it is critical to notify users of the error made during entry. For this reason, the useTsController() hook also returns an error object.

You can use useTsController to display the validation error message by adding a special feedback <span> to your input components:

// src/components/TextField.tsx

import { useDescription, useTsController } from "@ts-react/form"

export default function TextField() {
    const { label, placeholder } = useDescription()
    const { field, error  } = useTsController<string>()

    return (
        <>
            <label>{label}</label>
            <input
                placeholder={placeholder}
                value={field.value ? field.value : ""}
                onChange={(e) => {
                    field.onChange(e.target.value)
                }}
            />
            // the validation error <span>
            {error?.errorMessage && <span className={"error"}>{error?.errorMessage}</span>}
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

Then, you can specify a validation constraint and message in zod as below:

const LoginSchema = z.object({
  email: z.string()
      .describe("email@example.com")
      .email("Enter a valid email"), 
  password: z.string()
      .min(1, "Password must have at least 8 characters")
      .min(8, "Your password cannot be longer than 16 characters in length"), 
  keepSignedIn: z.boolean(),
})
Enter fullscreen mode Exit fullscreen mode

Zod comes with several built-in validation methods, but you can also define custom validation rules.

Et voilà! You can now build type-safe forms in React with custom input components, error handling, data validation, and no boilerplate involved! Check out the official react-ts-form docs to explore the remaining features offered by @ts-react/form.

Conclusion

In this article, you learned what it takes to build type-safe forms in React and how a powerful library like react-ts-form can help you with that.

As you saw here, popular form libraries like Formik do not have complete type-safe capabilities. Thus, they may not be the ideal solution for React form development in TypeScript.

With react-ts-form, you can easily build a type-safe form in TypeScript with a handful of lines of code, avoiding tedious and repetitive operations.


Get setup with LogRocket's modern React error tracking in minutes:

1.Visit https://logrocket.com/signup/ to get an app ID.
2.Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.

NPM:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
Enter fullscreen mode Exit fullscreen mode

Script Tag:

Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now

Top comments (0)