Github for this tutorial: https://github.com/PranavB6/tutorials
Created with:
const formSchema: FieldAttributes[] = [
{
name: "fullName",
label: "Full Name",
type: FieldType.TEXT,
},
{
name: "favAnimal",
label: "What is Your Favourite Pet?",
type: FieldType.SELECT,
options: [
{ label: "Dog 🐶", value: "dog" },
{ label: "Cat 😺", value: "cat" },
{ label: "Bird 🐦", value: "bird" },
{ label: "Fish 🐟", value: "fish" },
{ label: "Tasmanian Devil 😈", value: "devil" },
],
},
{
name: "agreeToTerms",
label: "I Agree to all Terms and Conditions",
type: FieldType.CHECKBOX,
},
];
The Problem: We wanted to create simple forms that could be easily modified by team members who are not developers. We also wanted to be able to easily add new forms to our application. We decided to use a YAML schema to define the form fields and their properties. We also wanted to use React Hook Form to handle the form state and validation.
When I was first tasked with this problem, I of course googled it to find a tutorial. However, although there are many tutorials on how to render forms from a schema using React, I could not find any that used Typescript and React Hook Form. After spending (wayyy too much) time experimenting I came up with a good approach. So I decided to write this tutorial to share my learnings and to help others who are in the same situation as I was.
🚀 High Level Solution: Get the schema from the server, loop through all the fields and render a "Field" component which will render a InputField Component or a SelectField Component or a Checkbox Component etc depending on the field "type". Use React Hook Form to handle the form state and validation. Use React Context (via React Hook Form's FormProvider Component) to manage the entire form state.
Setup Basic Form
If you want to follow along, please have a look at the appendix at the end of this tutorial to see how to setup a basic React Typescript project with TailwindCSS.
-
Create a basic form
// src/App.tsx import React from 'react'; import './App.css'; function App() { return ( <main className="main"> <h1>Very Important Form</h1> <form className="form"> <div> <label htmlFor="fullName">Full Name</label> <input name="fullName" id="fullName" type="text" /> </div> <div> <label htmlFor="favAnimal">What is Your Favourite Pet?</label> <select name="favAnimal" id="favAnimal"> <option value="dog">Dog 🐶</option> <option value="cat">Cat 😺</option> <option value="bird">Bird 🐦</option> <option value="fish">Fish 🐟</option> <option value="devil">Tasmanian Devil 😈</option> </select> </div> <div> <input type="checkbox" name="agreeToTerms" id="agreeToTerms" /> <label htmlFor="agreeToTerms"> I Agree to all Terms and Conditions </label> </div> <button type="submit">Submit</button> </form> </main> ); } export default App;
-
Setup React Hook Form to manage form state
import React, { useState } from "react"; import { useForm, FormProvider } from "react-hook-form"; import "./App.css"; function App() { const methods = useForm(); const onSubmitHandler = (values: any) => { console.log(`Submitted`); console.log(values); }; return ( <main className="main"> <h1>Very Important Form</h1> {/* setup form provider, so that we can use useFormContext in the input field component */} <FormProvider {...methods}> <form className="form" onSubmit={methods.handleSubmit(onSubmitHandler)}> <div> <label htmlFor="fullName">Full Name</label> <input {...methods.register("fullName")} id="fullName" type="text" /> </div> <div> <label htmlFor="favAnimal">What is Your Favourite Pet?</label> <select {...methods.register("favAnimal")} id="favAnimal"> <option value="dog">Dog 🐶</option> <option value="cat">Cat 😺</option> <option value="bird">Bird 🐦</option> <option value="fish">Fish 🐟</option> <option value="devil">Tasmanian Devil 😈</option> </select> </div> <div> <input {...methods.register("agreeToTerms")} type="checkbox" id="agreeToTerms" /> <label htmlFor="agreeToTerms"> I Agree to all Terms and Conditions </label> </div> <button type="submit">Submit</button> </form> {/* display the values of the form on the page */} <section> <pre>{JSON.stringify(methods.watch(), null, 2)}</pre> </section> </FormProvider> </main> ); } export default App;
Create InputField and SelectField Components
-
Extract a text input field into a separate component
// src/components/Fields/InputField.tsx import React from "react"; interface InputFieldProps { label: string; name: string; type: string; } const InputField: React.FC<InputFieldProps> = ({ label, name, type }) => { return ( <div> <label htmlFor={name}>{label}</label> <input name={name} id={name} type={type} /> </div> ); }; export default InputField;
-
use the
useFormContext
hook to manage the form state of our newInputField
component
// src/components/Fields/InputField.tsx ++ import { useFormContext } from "react-hook-form"; ... const InputField: React.FC<InputFieldProps> = (...) => { ++ const { register } = useFormContext(); return ( ... ++ <input {...register(name)} id={name} type={type} /> ... ); }; ...
-
Replace the first input in App.tsx with our new InputField component
// src/App.tsx ++ import InputField from "./Fields/InputField"; function App() { return ( ... <form> ... ++ <InputField label="Full Name" name="fullName" type="text" /> ... </form> ... ); }
-
Extract a select field into a separate component and manage the form state with
useFormContext
// src/components/SelectField.tsx import React from "react"; import { useFormContext } from "react-hook-form"; interface SelectFieldProps { label: string; name: string; options: { label: string; value: string }[]; } const SelectField: React.FC<SelectFieldProps> = ({ label, name, options }) => { const { register } = useFormContext(); return ( <div> <label htmlFor={name}>{label}</label> <select {...register(name)} id={name}> {options.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> </div> ); }; export default SelectField;
-
Replace the select field in App.tsx with our new SelectField component
// src/App.tsx ++ import SelectField from "./Fields/SelectField"; function App() { return ( ... <form> ... ++ <SelectField ++ label="What is Your Favourite Pet?" ++ name="favAnimal" ++ options={[ ++ { label: "Dog 🐶", value: "dog" }, ++ { label: "Cat 😺", value: "cat" }, ++ { label: "Bird 🐦", value: "bird" }, ++ { label: "Fish 🐟", value: "fish" }, ++ { label: "Tasmanian Devil 😈", value: "devil" }, ++ ]} ++ /> ... </form> ... ); }
Create FieldAttributes Interface which InputFieldAttributes and SelectFieldAttributes will extend from
-
Even in this small example, we can see that both fields share the
label
andname
attributes. We can imagine that these two properties (and probably more) will be common among all Fields, so lets extract these attributes into a separate interface and extend it in both theInputFieldProps
andSelectFieldProps
interfaces.
// --- BEFORE --- // src/components/Fields/InputField.tsx interface InputFieldProps { label: string; name: string; type: string; } // src/components/Fields/SelectField.tsx interface SelectFieldProps { label: string; name: string; options: { label: string; value: string }[]; } // --- AFTER --- // src/models/FieldAttributes.ts // (move all the interfaces to this file) enum FieldType { TEXT = "text", SELECT = "select", } interface BaseFieldAttributes { label: string; name: string; type: FieldType; } interface InputFieldAttributes extends BaseFieldAttributes { type: FieldType.TEXT; } // IMPORTANT: By setting the type to FieldType.SELECT, we are telling TypeScript that the 'options' property is required, so now we will get errors when the 'options' property is missing! This is a great thing! interface SelectFieldAttributes extends BaseFieldAttributes { type: FieldType.SELECT; options: { label: string; value: string }[]; } type FieldAttributes = InputFieldAttributes | SelectFieldAttributes; export default FieldAttributes; export { FieldType, type InputFieldAttributes, type SelectFieldAttributes };
-
Now we can import these interfaces into both the
InputField
andSelectField
components and use them to type the props.
// src/components/Fields/InputField.tsx ++ import { InputFieldAttributes } from "../../models/FieldAttributes"; ++ const InputField: React.FC<InputFieldAttributes> = (...) => { ... };
```typescript
// src/components/Fields/SelectField.tsx
++ import { SelectFieldAttributes } from "../../models/FieldAttributes";
++ const SelectField: React.FC<SelectFieldAttributes> = (...) => {
...
};
```
-
If you look back at
App.tsx
, you will see we broke some things so let's fix that.
// src/App.tsx import { FieldType } from "./models/FieldAttributes"; function App() { return ( ... <form> ... {/* Add type to both fields */} <InputField type={FieldType.TEXT} ... /> <SelectField type={FieldType.SELECT} ... /> ... </form> ... ); }
Create CheckboxField Component and use FieldAttributes Interface
-
Now let's practice using our new interfaces by using it to create a
CheckboxField
.
// src/models/FieldAttributes.ts enum FieldType { ..., // Add new field to the enum CHECKBOX = "checkbox", } // create interface for the props of the CheckboxField Component interface CheckboxFieldAttributes extends BaseFieldAttributes { type: FieldType.CHECKBOX; } // add the new interface to the union type type FieldAttributes = ... | CheckboxFieldAttributes; // export the new interface export { ..., type CheckboxFieldAttributes };
-
Now we can create the
CheckboxField
component.
import React from "react"; import { useFormContext } from "react-hook-form"; import { CheckboxFieldAttributes } from "../../models/FieldAttributes"; const CheckboxField: React.FC<CheckboxFieldAttributes> = ({ label, name, type, }) => { const { register } = useFormContext(); return ( <div> <input {...register(name)} type="checkbox" id={name} /> <label htmlFor={name}>{label}</label> </div> ); }; export default CheckboxField;
-
Now we can use the
CheckboxField
component inApp.tsx
.
// src/App.tsx ++ import CheckboxField from "./components/Fields/CheckboxField"; function App() { return ( ... <form> ... ++ <CheckboxField ++ label="I Agree to all Terms and Conditions" ++ name="agreeToTerms" ++ type={FieldType.CHECKBOX} ++ /> ... </form> ... ); }
Combine all Fields into a Single Field Component
-
We are almost done. Next lets combine all of the fields into a single
Field
component. This will allow us to pass in thelabel
,name
, andtype
as props and then render the correct field based on thetype
prop.
// src/components/Field.tsx import React from "react"; import FieldAttributes, { FieldType } from "../models/FieldAttributes"; import InputField from "./Fields/InputField"; import SelectField from "./Fields/SelectField"; import CheckboxField from "./Fields/CheckboxField"; const Field: React.FC<FieldAttributes> = (props) => { switch (props.type) { case FieldType.TEXT: return <InputField {...props} />; case FieldType.SELECT: return <SelectField {...props} />; case FieldType.CHECKBOX: return <CheckboxField {...props} />; default: throw new Error("Invalid Field Type"); } }; export default Field;
-
Now we can use the
Field
component inApp.tsx
.
// src/App.tsx ++ import Field from "./components/Field"; function App() { return ( ... <form> ++ <Field label="Full Name" name="fullName" type={FieldType.++TEXT} /> ++ <Field ++ label="What is Your Favourite Pet?" ++ name="favAnimal" ++ type={FieldType.SELECT} ++ options={[ ++ { label: "Dog 🐶", value: "dog" }, ++ { label: "Cat 😺", value: "cat" }, ++ { label: "Bird 🐦", value: "bird" }, ++ { label: "Fish 🐟", value: "fish" }, ++ { label: "Tasmanian Devil 😈", value: "devil" }, ++ ]} ++ /> ++ <Field ++ label="I Agree to all Terms and Conditions" ++ name="agreeToTerms" ++ type={FieldType.CHECKBOX} ++ /> ... </form> ... ); }
Create a JSON Config for the Form and Dynamicly Render the Form
-
Now we have a single component that can render any field type. This is a lot cleaner than having to render each field type individually. Next let's create a "json config" which we will iterate through to generate the form!
// src/App.tsx ++ import Field from "./components/Field"; ++ import FieldAttributes, { FieldType } from "./models/FieldAttributes"; ++ const formSchema: FieldAttributes[] = [ ++ { ++ name: "fullName", ++ label: "Full Name", ++ type: FieldType.TEXT, ++ }, ++ { ++ name: "favAnimal", ++ label: "What is Your Favourite Pet?", ++ type: FieldType.SELECT, ++ options: [ ++ { label: "Dog 🐶", value: "dog" }, ++ { label: "Cat 😺", value: "cat" }, ++ { label: "Bird 🐦", value: "bird" }, ++ { label: "Fish 🐟", value: "fish" }, ++ { label: "Tasmanian Devil 😈", value: "devil" }, ++ ], ++ }, ++ { ++ name: "agreeToTerms", ++ label: "I Agree to all Terms and Conditions", ++ type: FieldType.CHECKBOX, ++ }, ++ ]; function App() { ... return ( <main className="main"> ... <form ...> ++ {formSchema.map((field) => ( ++ <Field {...field} /> ++ ))} ... </form> ... </main> ); }
🙋 What if the form schema from my server is different from the form schema I want to use in my React app? This is a common problem when working with forms. The solution is to create a mapping between the two schemas. This mapping will tell your React app how to render the form fields from the schema you get from the server. This mapping will also tell your React app how to handle the form state and validation. For simplicity, I will keep the server schema and the React app schema the same.
Prerequisites
Setup the project
-
Create a React with Typescript app using create-react-app
npx create-react-app react-hook-form-server-forms-tutorial --template typescript
-
Install React Hook Form, TailwindCSS (for styling) with TailwindCSS Forms and Typography plugins
cd react-hook-form-server-forms-tutorial npm install react-hook-form tailwindcss @tailwindcss/forms @tailwindcss/typography
Setup TailwindCSS
-
Create a
tailwind.config.js
file in the root of the project
npx tailwindcss init
-
Add the following to the
tailwind.config.js
file
/** @type {import('tailwindcss').Config} */ module.exports = { ++ content: ["./src/**/*.{js,jsx,ts,tsx}"], theme: { extend: {}, }, ++ plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")], };
-
Copy this CSS into
index.css
/* src/index.css */ @tailwind base; @tailwind components; @tailwind utilities; @layer components { .main { @apply mt-10 mx-auto container w-1/3 prose; } .form { @apply grid grid-cols-1 gap-6; } .form label { @apply text-gray-700; } .form label[data-required="true"]::after { @apply ml-1 text-red-500; content: "*"; } .form input[type="text"], .form input[type="email"], .form input[type="date"], .form select, .form textarea { @apply mt-1 block w-full rounded-md border-gray-300 shadow-sm; /* for focus state */ @apply focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50; } .form input[type="checkbox"] { @apply mr-3 rounded border-gray-300 text-indigo-600 shadow-sm; /* for focus state */ @apply focus:border-indigo-300 focus:ring focus:ring-offset-0 focus:ring-indigo-200 focus:ring-opacity-50; } .form button { @apply block ml-auto mt-5 bg-indigo-600 text-white px-4 py-2 rounded-md transition; /* for focus state */ @apply focus:outline-none focus:bg-indigo-500 focus:border-indigo-300 focus:ring focus:ring-offset-0 focus:ring-indigo-200 focus:scale-105 active:scale-95; } .form p.error-message { @apply mt-2 mb-0 text-red-500; } }
Top comments (2)
I have built something similar in angular with help of reactive forms, and it worked great you just define the schema and form will be auto built for you. It also has conditional rendering so if you wanted to hide or show some fields based upon conditions.
But the problem that I faced was that when rendering 10 to 15 fields conditionally (and I had somewhere near to 40 I can't remember clearly). It would drop the frames significantly the response time went to 256ms to 300ms in somecases even more.
I don't know why it was was it because I was using alot of conditions inside loop as templates are not ment to do heavy computations or was it something else. I am still figuring out the solution to bring it below 10ms.
It seems like a rerendering issue. Maybe every keystroke causes every field to rerender instead of just one. Try making sure that components are only rerendered if the condition result changes, and not on every change to the variable.