Building Robust Forms from JSON using React Hook Form
In this article by Khalil Patiwala from GeekyAnts, learn how to create dynamic, type-safe forms in React using the JSON.
Managing forms in React applications can be complex, but React Hook Form simplifies it significantly. Combining it with TypeScript and JSON allows you to create highly robust and type-safe forms.
This blog post will guide you through creating dynamic form components like checkboxes, radios, inputs, and selects based on JSON schema data and adding relevant validations. In this tutorial, we will see how to create a form using JSON configuration.
What is React Hook Form?
React Hook Form is a lightweight library that makes form handling in React applications straightforward. Combined with TypeScript and Zod, it offers a powerful solution for creating forms with strong type safety and robust validation.
Why use JSON to create a form?
Before diving into the code, let's briefly discuss some advantages of using JSON when building a form.
One advantage is that the form code will be less verbose. You don't have to define multiple inputs within a single form manually. Another benefit is that fields and their controls can be generated dynamically. You can store configurations in a file or database and read them to generate the form.
If you have a use case where a form needs to be generated dynamically, like in a feedback form, using a JSON object to generate the form is a great choice.
Setting Up the Project
First, set up your project by installing the necessary dependencies:
npm install react-hook-form
Writing the structure of JSON
We first need to define the properties of our JSON to render the controls in the form dynamically. For that, we will create a file types.ts
to hold the type definitions and interfaces for various form fields.
import { RegisterOptions } from "react-hook-form";
export interface FormSchema {
fields: Field[];
}
export type Field = InputField | DropdownField | CheckboxField | RadioField;
interface FieldBase {
name: string;
label: string;
placeholder?: string;
validation?: RegisterOptions;
}
export interface InputField extends FieldBase {
type: "text" | "email" | "tel" | "number";
}
export type Option = { label: string; value: string };
export interface DropdownField extends FieldBase {
type: "select";
options: Option[];
}
export interface CheckboxField extends FieldBase {
type: "checkbox";
}
export interface RadioField extends FieldBase {
type: "radio";
options: Option[];
}
This includes various form field types such as InputField
, DropdownField
, CheckboxField
, and RadioField
. Each extends a base interface (FieldBase) with common properties like name, label, placeholder, and validation options. InputField
supports types like text
and email. DropdownField
and RadioField
include options for selection, while CheckboxField
represents a checkbox input. The overall structure creates form fields with specific configurations and validation rules.
In validation
property, we can configure our validations and the input behavior. The object looks like this. You can read all the options validations here.
{
required: "This Field is Required",
maxLength: 10,
minLength: 1,
pattern: /[A-Za-z]{3}/
}
Creating the Form Component
Once we have an overview of the JSON we will receive, we can begin creating the form. In the FormComponent
file, add the following code:
FormComponent.tsx
export deimport React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { FormSchema } from "./Fields/types";
import FormField from "./FormField";
type Props = {
schema: FormSchema;
};
const Form = ({ schema }: Props) => {
const methods = useForm();
return (
<form>
<FormProvider {...methods}>
{schema.fields.map((field, index) => {
return <FormField key={index} field={field} />;
})}
</FormProvider>
<button type="submit" className="button w-full">
Submit
</button>
</form>
);
};
export default Form;
This uses react-hook-form
to manage form state and validation. It takes the JSON Schema defining form fields and a onSubmit
function as props. Inside the component, useForm
initialize form methods, which are provided to child components via FormProvider
. The form renders fields based on the schema
using the FormField
component and includes a submit button that triggers the onSubmit
function with the form data.
Creating dynamic fields for various types
Next, we'll create individual components for each field type: TextField
, DropdownField
, CheckboxField
, and RadioField
.
TextField.tsx
import React from "react";
import { Controller, useFormContext } from "react-hook-form";
import Error from "./Error";
import type { FieldProps, InputField } from "./types";
const TextField = ({ field }: FieldProps<InputField>) => {
const { control } = useFormContext();
return (
<Controller
name={field.name}
defaultValue={field.defaultValue || ""}
control={control}
rules={field.validation}
render={({ field: { onChange, ...rest }, fieldState: { error } }) => (
<div>
<label htmlFor={field.name}>{field.label}</label>
<input
id={field.name}
type={field.type}
placeholder={field.placeholder}
onChange={onChange}
{...rest}
/>
{error && <Error error={error} />}
</div>
)}
/>
);
};
export default TextField;
DropdownField.tsx
import React from "react";
import { Controller, useFormContext } from "react-hook-form";
import Error from "./Error";
import { DropdownField as Dropdown, FieldProps } from "./types";
const DropdownField = ({ field }: FieldProps<Dropdown>) => {
const { control } = useFormContext();
return (
<Controller
name={field.name}
control={control}
defaultValue={field.defaultValue || ""}
rules={field.validation}
render={({ field: { onChange, ...rest }, fieldState: { error } }) => (
<div>
<label htmlFor={field.name}>{field.label}</label>
<select id={field.name} onChange={onChange} {...rest}>
<option value="">Select...</option>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && <Error error={error} />}
</div>
)}
/>
);
};
export default DropdownField;
CheckboxField.tsx
import React from "react";
import { Controller, useFormContext } from "react-hook-form";
import Error from "./Error";
import { CheckboxField as Checkbox, FieldProps } from "./types";
const CheckboxField = ({ field }: FieldProps<Checkbox>) => {
const { control } = useFormContext();
return (
<Controller
name={field.name}
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<div>
<label>
<input
type="checkbox"
checked={value}
onChange={(e) => onChange(e.target.checked)}
/>
{field.label}
</label>
{error && <Error error={error} />}
</div>
)}
/>
);
};
export default CheckboxField;
RadioField.tsx
import React from "react";
import { Controller, useFormContext } from "react-hook-form";
import Error from "./Error";
import type { FieldProps, RadioField as Radio } from "./types";
const RadioField = ({ field }: FieldProps<Radio>) => {
const { control } = useFormContext();
return (
<Controller
name={field.name}
control={control}
defaultValue={field.defaultValue || ""}
rules={field.validation}
render={({ field: { onChange }, fieldState: { error } }) => (
<div>
<label>{field.label}</label>
{field.options.map((option) => (
<div key={option.value}>
<input
id={option.value}
type="radio"
value={option.value}
onChange={() => onChange(option.value)}
/>
<label htmlFor={option.value}>{option.label}</label>
</div>
))}
{error && <Error error={error} />}
</div>
)}
/>
);
};
export default RadioField;
We will also add an Error component to show an error message if validation fails for a field.
Error.ts
import React from "react";
import type { FieldError } from "react-hook-form";
const Error = ({ error }: { error: FieldError }) => {
return <span className="text-red-500 text-xs block">{error.message}</span>;
};
export default Error;
Now that we have built the individual fields for our form. We will work on dynamically rendering this in our form component. You can do so by creating a component FormField
and adding the following code.
FormField.tsx
import React from "react";
import type { Field } from "./Fields/types";
import dynamic from "next/dynamic";
const TextField = dynamic(() => import("./Fields/TextField"));
const DropdownField = dynamic(() => import("./Fields/DropdownField"));
const RadioField = dynamic(() => import("./Fields/RadioField"));
const CheckboxField = dynamic(() => import("./Fields/CheckboxField"));
const FormField = ({ field }: { field: Field }) => {
return (
<>
{(() => {
switch (field.type) {
case "text":
case "email":
case "tel":
case "number":
return <TextField field={field} />;
case "select":
return <DropdownField field={field} />;
case "checkbox":
return <CheckboxField field={field} />;
case "radio":
return <RadioField field={field} />;
default:
return null;
}
})()}
</>
);
};
export default FormField;
Render the form
So far, we have created the Form and Dynamic Form Fields, but we aren’t using them anywhere yet. Why don't we create the real JSON data before we can render the form in the App component?
data.ts
import { FormSchema } from "@/components/Form/Fields/types";
export const fields: FormSchema = {
fields: [
{
label: "Name",
type: "text",
name: "name",
validation: {
required: "This is required",
minLength: { value: 5, message: "Name must be at least 5 characters" },
},
placeholder: "Enter your name",
},
{
name: "age",
type: "number",
label: "Age",
defaultValue: 18,
validation: {
required: "This is required",
min: { value: 18, message: "Age must be 18 or older" },
},
},
{
label: "Favorite Color",
type: "select",
name: "favorite_color",
validation: {
required: "This is required",
},
options: [
{ value: "english", label: "English" },
{ value: "hindi", label: "Hindi" },
{ value: "spanish", label: "Spanish" },
],
},
{
label: "Gender",
type: "radio",
name: "gender",
validation: {
required: "This is required",
},
options: [
{ value: "male", label: "Male" },
{ value: "female", label: "Female" },
],
},
{
label: "Accept Terms",
type: "checkbox",
name: "accept_terms",
validation: {
required: "You must accept the terms",
},
},
],
};
Now that we have the JSON field configurations let's render the Form component. Inside App.tsx
, import the Form
component and pass it the JSON configuration defined above.
App.tsx
import React from 'react';
import type { NextPage } from "next";
import { fields } from "@/components/Form/data";
import Form from "@/components/Form";
const App: NextPage = () => {
return (
<main className="w-1/2 mx-auto border m-24 p-10 bg-white">
<h1 className="font-bold text-2xl">React Hook Form</h1>
<Form schema={fields} />
</main>
);
)};
export default App;
If everything goes well, you should see something like this.
Note: Additional styles are added to make the form look good.
To see validation in action, refer to the image below.
Submitting the form
React hook form library provides a function to submit the form. One of the properties that the useForm hook returns is the handleSubmit
function. It is a function that returns another function.
While the form is being submitted, it is also important that we disable the button to avoid duplicate submission. To achieve this, we will be using another property formState
that holds information about the state of the form. We can extract isSubmitting
property to know when the form is being submitted.
Let us see how we can handle the form submission and disable the submit button in our Form
component.
const methods = useForm();
const {
handleSubmit,
formState: { isSubmitting },
} = methods;
const onSubmit = (data) => {
// Add your logic to process the data
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FormProvider {...methods}>
{schema.fields.map((field, index) => {
return <FormField key={index} field={field} />;
})}
</FormProvider>
<button type="submit" disabled={isSubmitting}>
Submit
</button>
</form>
);
Conclusion
In this tutorial, we built a dynamic form in React using TypeScript, JSON, and React Hook Form. We created reusable components for different field types, dynamically rendered fields based on JSON, and handled validation. This tutorial is just a starting point, but you can extend it to your use case.
You can find the code for this tutorial here.