Simplifying Form Rendering In React with Field Component Abstraction
Updated on · 5 min read|Creating forms in React is a straightforward process, but it's common for developers to run into the problem of repetitive markup around form fields. When dealing with multiple fields, each requiring labels, validation, error messages, and layout styling, the code can quickly become bloated and hard to maintain. This is particularly true when working with dynamic forms, multistep forms or other forms with a large number of fields.
To tackle these issues, abstracting field rendering logic into a reusable component can be a game-changer. This post will explain how you can use the Field
component to minimize repetition and promote cleaner and more maintainable React forms.
Basic form
Consider a basic recipe form component, built with React Hook Form, which has a few input fields, each accompanied by a label and form validation with potential error messages. Here's a simplified version of what the form might look like:
jsximport React from "react"; import { useForm } from "react-hook-form"; export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm(); const submitForm = (formData) => { console.log(formData); }; return ( <div> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <fieldset> <legend>Basics</legend> <div> <label htmlFor="name">Name</label> <input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> {errors.name && <p>{errors.name.message}</p>} </div> <div> <label htmlFor="description">Description</label> <textarea {...register("description", { maxLength: { value: 100, message: "Description cannot be longer than 100 characters", }, })} id="description" rows={10} /> {errors.description && <p>{errors.description.message}</p>} </div> <div> <label htmlFor="amount">Servings</label> <input {...register("amount", { max: { value: 10, message: "Maximum number of servings is 10", }, })} type="number" id="amount" /> {errors.amount && <p>{errors.amount.message}</p>} </div> </fieldset> <div> <button>Save</button> </div> </form> </div> ); };
jsximport React from "react"; import { useForm } from "react-hook-form"; export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm(); const submitForm = (formData) => { console.log(formData); }; return ( <div> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <fieldset> <legend>Basics</legend> <div> <label htmlFor="name">Name</label> <input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> {errors.name && <p>{errors.name.message}</p>} </div> <div> <label htmlFor="description">Description</label> <textarea {...register("description", { maxLength: { value: 100, message: "Description cannot be longer than 100 characters", }, })} id="description" rows={10} /> {errors.description && <p>{errors.description.message}</p>} </div> <div> <label htmlFor="amount">Servings</label> <input {...register("amount", { max: { value: 10, message: "Maximum number of servings is 10", }, })} type="number" id="amount" /> {errors.amount && <p>{errors.amount.message}</p>} </div> </fieldset> <div> <button>Save</button> </div> </form> </div> ); };
From the code above, it's clear that there is a noticeable amount of repetition. Each input field is wrapped in a div
, which includes a label
and potentially an error
message. Changes to common aspects, like error message styling or label positioning, would require modifications in multiple places.
The traditional form code, as shown, could lead to several difficulties:
- Repetitive: the same patterns of HTML repeated for each form field, making the code verbose.
- Prone to inconsistencies: when there are multiple instances to update, there is a higher chance of introducing inconsistencies, as it's easy to miss updating one or more occurrences.
- Maintenance overhead: any change made to a label, input, or error message requires sifting through the boilerplate code for every field, increasing the time and effort needed for simple updates.
- Difficult to scale: as the form grows with more fields, the complexity and the volume of the code continue to increase, which can be overwhelming and harder to manage.
Abstracting the field rendering logic
To address these issues, we can create a Field
component that encapsulates the logic for rendering a form field, including the label, input, and error message. This component will allow us to abstract away the repetitive markup and provide a more consistent and maintainable way to render form fields.
Here's a look at the Field
component we'll be discussing:
jsx// Field.js import React from "react"; export const Field = ({ label, htmlFor, error, children }) => { const id = htmlFor || getChildId(children); return ( <div className="form-field"> {label && <label htmlFor={id}>{label}</label>} {children} {error && ( <div role={"alert"} className="error"> {error} </div> )} </div> ); }; function getChildId(children) { const child = React.Children.only(children); if ("id" in child?.props) { return child.props.id; } }
jsx// Field.js import React from "react"; export const Field = ({ label, htmlFor, error, children }) => { const id = htmlFor || getChildId(children); return ( <div className="form-field"> {label && <label htmlFor={id}>{label}</label>} {children} {error && ( <div role={"alert"} className="error"> {error} </div> )} </div> ); }; function getChildId(children) { const child = React.Children.only(children); if ("id" in child?.props) { return child.props.id; } }
This Field
component abstracts away the concern of rendering a form label, the form field itself, and any associated error messages into a neat package. The beauty of this component lies in its simplicity and the flexibility it offers. It can be used with any form library or strategy, such as React Hook Form, Formik, or plain HTML forms. It also allows for customizations to be made to the label, input, and error message rendering. Let's break down the Field
component to understand how it works.
Props
The Field
component accepts the following props:
label
: The text to be used as the label for the form field.htmlFor
: Theid
of the form field the label is associated with. If not provided, theField
component will attempt to extract theid
from the child input.error
: The error message to be displayed when the form field is invalid.children
: The form field input element.
Implementation
The Field
component is a simple functional component that takes in the props mentioned above. The label
and error
(both optional) props are used to render the label element and error message, respectively. The children
prop is used to render the form field input element.
The htmlFor
prop is used to associate the label with the form field input. If the htmlFor
prop is not provided, the Field
component attempts to extract the id
from the child input using the getChildId
function. This function uses the React.Children.only
method to get the single child element and then checks if the id
prop is present in the child input. If it is, the id
is returned; otherwise, it is undefined
.
Benefits of the Field component
- Consistent styling: although the current example doesn't have any styles applied, including the label, input, and error message within the same wrapper ensures consistent styling across all form fields, should it be added later.
- Reduced code duplication: using the
Field
component across the forms means writing the markup for a form label and error messages only once. - Easier maintenance: in case there's a need to update the styling or logic for how fields render, it can be done in one place rather than having to update every form.
- Enhanced readability: the forms can read more like a list of fields rather than a mix of labels, inputs, and error messages, making it easier to understand at a glance.
- Automatic label association: by extracting the
id
from the child input and setting it as thehtmlFor
attribute on the label, theField
component creates an implicit association, which is great for accessibility and testing, particularly when testing with React testing Library.
Usage Example
Now we can use the Field
component in the RecipeForm
component to render the form fields. Here's how the RecipeForm
component looks after using the Field
component:
jsximport React from "react"; import { useForm } from "react-hook-form"; import { Field } from "./Field"; export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm(); const submitForm = (formData) => { console.log(formData); }; return ( <div> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <fieldset> <legend>Basics</legend> <Field label="Name" error={errors.name?.message}> <input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> </Field> <Field label="Description" error={errors.description?.message}> <textarea {...register("description", { maxLength: { value: 100, message: "Description cannot be longer than 100 characters", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount?.message}> <input {...register("amount", { max: { value: 10, message: "Maximum number of servings is 10", }, })} type="number" id="amount" /> </Field> </fieldset> <Field> <button>Save</button> </Field> </form> </div> ); };
jsximport React from "react"; import { useForm } from "react-hook-form"; import { Field } from "./Field"; export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm(); const submitForm = (formData) => { console.log(formData); }; return ( <div> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <fieldset> <legend>Basics</legend> <Field label="Name" error={errors.name?.message}> <input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> </Field> <Field label="Description" error={errors.description?.message}> <textarea {...register("description", { maxLength: { value: 100, message: "Description cannot be longer than 100 characters", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount?.message}> <input {...register("amount", { max: { value: 10, message: "Maximum number of servings is 10", }, })} type="number" id="amount" /> </Field> </fieldset> <Field> <button>Save</button> </Field> </form> </div> ); };
As you can see, the Field
component has significantly reduced the repetition in the form code. Each form field is now wrapped in a Field
component, which includes the label, input, and error message. This makes the form code cleaner, more maintainable, and easier to read.
The Field
component is a simple yet powerful abstraction that can greatly simplify form rendering in React. It's a great tool to have in your toolkit when working with forms in React. It can be used to create more consistent and maintainable forms, and it can save a lot of time and effort when working with forms in React.
Conclusion
Abstracting the form field logic into a Field
component can significantly reduce the complexity of form markup in React. Encapsulating labels, inputs, error messages, and other repeating logic creates a more maintainable and scalable codebase.
This approach adheres to the DRY (Don't Repeat Yourself) principle and makes future form modifications a breeze, as you would only need to update the implementation in one component. Whether you're building a simple contact form or a complex multistep form, considering such abstractions will enhance your development workflow and result in cleaner, more readable code.
References and resources
- Advanced Multistep Forms with React Hook Form
- Build Dynamic Forms with React Hook Form
- Build a Multistep Form With React Hook Form
- Creating Accessible Form Components with React
- Form Validation with React Hook Form
- Improving React Testing Library Tests
- Managing Forms with React Hook Form
- React Hook Form
- Testing React Hook Form With React Testing Library
- Wikipedia: Don't Repeat Yourself principle