Building Robust Forms from JSON using React Hook Form

Aditi DixitAditi Dixit
8 min read

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.

References

0
Subscribe to my newsletter

Read articles from Aditi Dixit directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Aditi Dixit
Aditi Dixit