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:
- Dealing with forms in React
- How
react-ts-form
makes building type-safe forms possible in React - Pros and cons of
react-ts-form
- Creating an example type-safe form with TypeScript in React
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:
- Defining form components and input fields
- Managing form state for input values and validation errors
- Monitoring user input and updating the status accordingly
- Creating validation functions and applying them to form fields
- 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:
- Prerequisites to getting started with
react-ts-form
- Defining a type-safe form component
- Handling type collisions
- Creating input components
- Adding non-input components
- Validation and error handling
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 incompilerOptions
totrue
in yourtsconfig.json
file to enable stricter type checking - Verify that your TypeScript is v4.9 or newer
- Set the
- 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
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
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
}}
/>
)
}
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.
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
})
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(),
})
Keep in mind that labels and placeholders must be specified in the following format:
<LABEL> // <PLACEHOLDER>
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...
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...
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)
}}
/>
</>
)
}
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 }) {
// ...
}
<MyTypeSafeForm>
in <LoginForm>
will now throw a type error: 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: 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
}
}}
/>
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 />
}
}}
/>
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>}
</>
)
}
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(),
})
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');
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>
3.(Optional) Install plugins for deeper integrations with your stack:
- Redux middleware
- ngrx middleware
- Vuex plugin
Top comments (0)