Build Dynamic Forms with React Hook Form
Updated on · 5 min read|Developers often encounter the need to implement multiple forms that are visually similar but vary slightly in their fields. This is typical when creating configuration forms for different services. While the most straightforward approach might be to create a distinct form for each service, this method comes with several disadvantages: it's difficult to maintain, not easily scalable and lacks flexibility. A more effective solution is to design a dynamic form capable of accommodating any number of services with their respective configuration options.
In this article, we'll design a dynamic form to manage configuration for various social media integrations. We'll be working with four social media providers — Facebook, Twitter, Instagram, and LinkedIn — each having its unique set of configuration options. Rather than building a form filled with complex conditional logic or creating individual forms for each provider, or even creating a multistep form, we will pursue a more efficient strategy by crafting one adaptable form. This dynamic form will be designed to handle the distinct requirements of any number of providers and their configuration settings.
Getting Started
The idea here is to first define an array of field names for each provider. We'll also need a map with more detailed information about each field. This map will contain the field name, label, type, and validation options. We'll use this map to render the form fields and also to validate the form. Finally, we'll have a Form
component that will render the form fields based on their type and handle the form submission. We'll use React Hook Form to handle the form state and validation.
The Data
First, we'll need to define the types for each provider. After that, we can combine them into a single type called SocialMediaIntegrationConfig
to have a type for all the available fields. Additionally, we'll define a type for the provider names called Provider
.
ts// types.ts type FacebookConfig = { appID: string; appSecret: string; pageAccessToken: string; defaultPostPrivacy: string; enableAnalytics: boolean; scopes: string[]; }; type TwitterConfig = { apiKey: string; apiSecret: string; accessToken: string; accessTokenSecret: string; enableRetweets: boolean; scopes: string[]; }; type InstagramConfig = { clientID: string; clientSecret: string; accessToken: string; enableTagging: boolean; scopes: string[]; }; type LinkedInConfig = { clientID: string; clientSecret: string; accessToken: string; scopes: string[]; }; export type Provider = "facebook" | "twitter" | "instagram" | "linkedin"; export type SocialMediaIntegrationConfig = FacebookConfig & TwitterConfig & InstagramConfig & LinkedInConfig;
ts// types.ts type FacebookConfig = { appID: string; appSecret: string; pageAccessToken: string; defaultPostPrivacy: string; enableAnalytics: boolean; scopes: string[]; }; type TwitterConfig = { apiKey: string; apiSecret: string; accessToken: string; accessTokenSecret: string; enableRetweets: boolean; scopes: string[]; }; type InstagramConfig = { clientID: string; clientSecret: string; accessToken: string; enableTagging: boolean; scopes: string[]; }; type LinkedInConfig = { clientID: string; clientSecret: string; accessToken: string; scopes: string[]; }; export type Provider = "facebook" | "twitter" | "instagram" | "linkedin"; export type SocialMediaIntegrationConfig = FacebookConfig & TwitterConfig & InstagramConfig & LinkedInConfig;
Next, we'll define the field names for each provider. These are the keys from the types we have defined previously. The field names will be stored in an array, so we have control over the order in which they are rendered.
ts// fields.ts import { SocialMediaIntegrationConfig, Provider } from "./types"; export const fields: Record< Provider, Array<keyof SocialMediaIntegrationConfig> > = { facebook: [ "appID", "appSecret", "pageAccessToken", "defaultPostPrivacy", "enableAnalytics", "scopes", ], twitter: [ "apiKey", "apiSecret", "accessToken", "accessTokenSecret", "enableRetweets", "scopes", ], instagram: [ "clientID", "clientSecret", "accessToken", "enableTagging", "scopes", ], linkedin: ["clientID", "clientSecret", "accessToken", "scopes"], };
ts// fields.ts import { SocialMediaIntegrationConfig, Provider } from "./types"; export const fields: Record< Provider, Array<keyof SocialMediaIntegrationConfig> > = { facebook: [ "appID", "appSecret", "pageAccessToken", "defaultPostPrivacy", "enableAnalytics", "scopes", ], twitter: [ "apiKey", "apiSecret", "accessToken", "accessTokenSecret", "enableRetweets", "scopes", ], instagram: [ "clientID", "clientSecret", "accessToken", "enableTagging", "scopes", ], linkedin: ["clientID", "clientSecret", "accessToken", "scopes"], };
Now we need to add some metadata to these fields, necessary for their rendering. We'll create a map where the field names serve as keys, and objects containing the field data act as values.
ts// fields.ts export type FieldData = { label: string; type: string; validation?: { required?: boolean; message?: string; }; allowCustomValue?: boolean; options?: string[]; placeholder?: string; }; export const fieldMap: Record<keyof SocialMediaIntegrationConfig, FieldData> = { appID: { label: "App ID", type: "text", validation: { required: true, message: "This field is required", }, }, appSecret: { label: "App Secret", type: "password", validation: { required: true, message: "This field is required", }, }, pageAccessToken: { label: "Page Access Token", type: "text", validation: { required: true, message: "This field is required", }, }, defaultPostPrivacy: { label: "Default Post Privacy", type: "text", validation: { required: true, message: "This field is required", }, }, enableAnalytics: { label: "Enable Analytics", type: "checkbox", }, scopes: { label: "Scopes", type: "select", options: ["team", "email"], }, apiKey: { label: "API Key", type: "text", validation: { required: true, message: "This field is required", }, }, apiSecret: { label: "API Secret", type: "password", validation: { required: true, message: "This field is required", }, }, accessToken: { label: "Access Token", type: "text", validation: { required: true, message: "This field is required", }, }, accessTokenSecret: { label: "Access Token Secret", type: "password", validation: { required: true, message: "This field is required", }, }, enableRetweets: { label: "Enable Retweets", type: "checkbox", }, enableTagging: { label: "Enable Tagging", type: "checkbox", }, clientID: { label: "Client ID", type: "text", validation: { required: true, message: "This field is required", }, }, clientSecret: { label: "Client Secret", type: "password", validation: { required: true, message: "This field is required", }, }, };
ts// fields.ts export type FieldData = { label: string; type: string; validation?: { required?: boolean; message?: string; }; allowCustomValue?: boolean; options?: string[]; placeholder?: string; }; export const fieldMap: Record<keyof SocialMediaIntegrationConfig, FieldData> = { appID: { label: "App ID", type: "text", validation: { required: true, message: "This field is required", }, }, appSecret: { label: "App Secret", type: "password", validation: { required: true, message: "This field is required", }, }, pageAccessToken: { label: "Page Access Token", type: "text", validation: { required: true, message: "This field is required", }, }, defaultPostPrivacy: { label: "Default Post Privacy", type: "text", validation: { required: true, message: "This field is required", }, }, enableAnalytics: { label: "Enable Analytics", type: "checkbox", }, scopes: { label: "Scopes", type: "select", options: ["team", "email"], }, apiKey: { label: "API Key", type: "text", validation: { required: true, message: "This field is required", }, }, apiSecret: { label: "API Secret", type: "password", validation: { required: true, message: "This field is required", }, }, accessToken: { label: "Access Token", type: "text", validation: { required: true, message: "This field is required", }, }, accessTokenSecret: { label: "Access Token Secret", type: "password", validation: { required: true, message: "This field is required", }, }, enableRetweets: { label: "Enable Retweets", type: "checkbox", }, enableTagging: { label: "Enable Tagging", type: "checkbox", }, clientID: { label: "Client ID", type: "text", validation: { required: true, message: "This field is required", }, }, clientSecret: { label: "Client Secret", type: "password", validation: { required: true, message: "This field is required", }, }, };
At first glance, it may seem like a lot is happening, but the process is quite straightforward. We have a map where the field names serve as keys, and objects containing the field data act as values. The field data includes the label, type, validation options, and additional field-specific information. We'll utilize this map both to render the form fields and to validate them. The validation object should align with the validation object's API provided by React Hook Form.
The Form
With our data in place, we can proceed to construct the form. We'll create a Form
component responsible for rendering the fields based on their type and managing form submissions. This component will include a renderField
method to display each form field according to its designated type. Aside from that, the form's structure is relatively simple.
Before incorporating the Form component, we should abstract a portion of the rendering logic into a separate Field
component. This component will be tasked with presenting the label, the error message, and the form field itself, based on the provided field data. Additionally, we'll include a helper function to extract the id
prop from a child element, which will be useful for associating the label with the form field, improving accessibility.
tsx// Field.tsx import React from "react"; interface FieldProps { children: React.ReactElement; label?: string; htmlFor?: string; required?: boolean; error?: { message?: string; }; } export const Field = ({ children, label, error, htmlFor, required, }: FieldProps) => { const id = htmlFor || getChildId(children); return ( <div className="col-sm-12 mb-3 flex flex-col items-start"> {label && ( <label htmlFor={id} className="form-label"> {label} {required && "*"} </label> )} {children} {error && <small className="error">{error.message}</small>} </div> ); }; // Get id prop from a child element export const getChildId = (children: FieldProps["children"]) => { const child = React.Children.only(children); if ("id" in child?.props) { return child.props.id; } };
tsx// Field.tsx import React from "react"; interface FieldProps { children: React.ReactElement; label?: string; htmlFor?: string; required?: boolean; error?: { message?: string; }; } export const Field = ({ children, label, error, htmlFor, required, }: FieldProps) => { const id = htmlFor || getChildId(children); return ( <div className="col-sm-12 mb-3 flex flex-col items-start"> {label && ( <label htmlFor={id} className="form-label"> {label} {required && "*"} </label> )} {children} {error && <small className="error">{error.message}</small>} </div> ); }; // Get id prop from a child element export const getChildId = (children: FieldProps["children"]) => { const child = React.Children.only(children); if ("id" in child?.props) { return child.props.id; } };
Now we can create the Form
component. We'll use the useForm
hook from React Hook Form to handle the form state and validation. We'll also pass the provider name as a prop to the Form
component. This prop will be used to determine which fields to render.
tsximport { Controller, useForm } from "react-hook-form"; import { Provider, SocialMediaIntegrationConfig } from "./types"; import { FieldData, fieldMap, fields } from "./fields"; import { Field } from "./Field"; interface FormProps { provider: Provider; } export const Form = ({ provider }: FormProps) => { const { register, handleSubmit, control, formState: { errors }, } = useForm<SocialMediaIntegrationConfig>(); const providerFields = fields[provider]; const onSubmit = async (data: SocialMediaIntegrationConfig) => { console.log("Saving data", data); }; const renderField = ( name: keyof SocialMediaIntegrationConfig, fieldData: FieldData, ) => { switch (fieldData.type) { case "text": case "password": return ( <Field label={fieldData.label} required={!!fieldData.validation?.required} error={errors[name]} key={name} > <input {...register(name, { required: fieldData.validation?.message, })} type={fieldData.type} id={name} autoComplete={"off"} /> </Field> ); case "select": return ( <Field label={fieldData.label} htmlFor={name} key={name} error={errors[name]} > <Controller rules={fieldData.validation} name={name} control={control} render={({ field: { ref, value, onChange, ...fieldProps }, fieldState: { invalid }, }) => { return ( <select {...fieldProps} value={typeof value === "string" ? value : ""} > {fieldData.placeholder && ( <option value="" disabled> {fieldData.placeholder} </option> )} {(fieldData.options || []).map((option) => ( <option key={option} value={option}> {option} </option> ))} </select> ); }} /> </Field> ); case "checkbox": return ( <Field label={fieldData.label} htmlFor={name} key={name} error={errors[name]} > <input {...register(name, { required: fieldData.validation?.message })} type={fieldData.type} id={name} autoComplete={"off"} /> </Field> ); default: throw new Error(`Unknown field type: ${fieldData.type}`); } }; return ( <div style={{ padding: "24px" }}> <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: "600px" }}> <> {providerFields.map((fieldName) => { const field = fieldMap[fieldName]; return renderField(fieldName, field); })} <div> <Field> <button>{"Save"}</button> </Field> </div> </> </form> </div> ); };
tsximport { Controller, useForm } from "react-hook-form"; import { Provider, SocialMediaIntegrationConfig } from "./types"; import { FieldData, fieldMap, fields } from "./fields"; import { Field } from "./Field"; interface FormProps { provider: Provider; } export const Form = ({ provider }: FormProps) => { const { register, handleSubmit, control, formState: { errors }, } = useForm<SocialMediaIntegrationConfig>(); const providerFields = fields[provider]; const onSubmit = async (data: SocialMediaIntegrationConfig) => { console.log("Saving data", data); }; const renderField = ( name: keyof SocialMediaIntegrationConfig, fieldData: FieldData, ) => { switch (fieldData.type) { case "text": case "password": return ( <Field label={fieldData.label} required={!!fieldData.validation?.required} error={errors[name]} key={name} > <input {...register(name, { required: fieldData.validation?.message, })} type={fieldData.type} id={name} autoComplete={"off"} /> </Field> ); case "select": return ( <Field label={fieldData.label} htmlFor={name} key={name} error={errors[name]} > <Controller rules={fieldData.validation} name={name} control={control} render={({ field: { ref, value, onChange, ...fieldProps }, fieldState: { invalid }, }) => { return ( <select {...fieldProps} value={typeof value === "string" ? value : ""} > {fieldData.placeholder && ( <option value="" disabled> {fieldData.placeholder} </option> )} {(fieldData.options || []).map((option) => ( <option key={option} value={option}> {option} </option> ))} </select> ); }} /> </Field> ); case "checkbox": return ( <Field label={fieldData.label} htmlFor={name} key={name} error={errors[name]} > <input {...register(name, { required: fieldData.validation?.message })} type={fieldData.type} id={name} autoComplete={"off"} /> </Field> ); default: throw new Error(`Unknown field type: ${fieldData.type}`); } }; return ( <div style={{ padding: "24px" }}> <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: "600px" }}> <> {providerFields.map((fieldName) => { const field = fieldMap[fieldName]; return renderField(fieldName, field); })} <div> <Field> <button>{"Save"}</button> </Field> </div> </> </form> </div> ); };
Not much is happening in this component. The core of the form lies in the renderField
method, which renders the form field based on its type. Since we have only four field types, the method is not overly large. While we could abstract the rendering logic for the Field
, I believe it's acceptable to keep it here for now. Alternatively, we could extract the entire rendering logic into a separate component, but having it within the form component facilitates easier access to the useForm
hook methods.
With these modifications, adding a new field to the form becomes simple – just define it in the fieldMap
and add it to the appropriate fields array, and it will be rendered automatically. Extending the rendering logic is also straightforward in case new requirements arise.
Conclusion
In conclusion, the approach of building dynamic forms with React Hook Form offers a versatile and scalable solution for dealing with forms that need to cater to a variety of configurations. The power of this method is in its modularity and maintainability. By using an array of field names combined with a detailed map of field data, we can effortlessly render various form controls and implement validation without cluttering the codebase with redundant forms or entangled conditional logic.
The post demonstrates how to structure your types, define your fields and field metadata, and finally, construct a Form component that can be expanded or modified with ease. The use of React Hook Form simplifies state management and validation, ensuring a clean and understandable implementation. Whether developers are building forms for a handful of options or complex settings across numerous services, the principles outlined in this article will help them create a robust and adaptable form system. With this approach, tackling new requirements or making changes involves minimal hassle, illustrating the benefits of embracing React's compositional model alongside powerful libraries like React Hook Form for form management.