By Tim McVinish
Forms are at the heart of almost every app. And it’s no secret that React, all its greatness aside, doesn’t provide the nicest implementations for working with forms. Recently I had a project that centred around a large complex form. The UI contained unique custom inputs, dynamic fields, input validations, and the ability to highlight fields whose value had been modified from a baseline state. After much experimenting and multiple iterations I ended up with a reusable pattern for form components that ticked all my boxes and even simplifies the complexity that can be associated with React forms.
While planning the project I was fairly sure that the React Hook Form (RHF) library would be a strong candidate to help wrangle the form complexity. This article is going to focus on setting up RHF and applying it to standard form input components. With that said, this pattern can be extended to accomodate the any custom input.
Project setup
This pattern builds upon the React Hook Form (RHF) library, using it to wrangle a lot of the form complexities.
To demonstrate how we can use RHF to simplify our forms we’ll be building a small demo app. If you want to follow along you can either intialise a blank React project however you see fit. I’ve opted for Vite, typescript and MUI in addition to React Hook Form. Or, for those who want to just dig through some code, checkout the embedded Stackblitz at the end.
Here is the shape of the object we’ll be working with.
import { Project, IStatus } from "./types"; export const mockData: Project = { id: 1, title: "Acme Co", description: "This is the project description", status: "1", deliverables: [ { id: 1, title: "Deliverable 1", description: "This is deliverable 1", status: "1", }, { id: 2, title: "Deliverable 2", description: "This is deliverable 2", status: "2", }, { id: 3, title: "Deliverable 3", description: "This is deliverable 3", status: "3", }, ], };
Form setup
To initialise our form object we call RHF’s useForm() hook. useForm takes an optional object which can contain a range of optional properties; for this demo we’ll use the defaultValues and resolver properties. By passing our mock data object to defaultValues we’re providing a baseline with which RHF can initialise each field and track changes against. RHF includes built in field validation, however the resolver prop allows us to use a schema validation library, such as Yup or Zod, to provide even greater control over field validation. For this demo we’ll provide a simple Yup object, however it’s worth checking out the RHF and Yup/Zod docs if you’re looking for customised validation.
import { AppBar, Stack, Toolbar, Typography } from "@mui/material"; import { FormProvider, useForm } from "react-hook-form"; import { ProjectEditor } from "./ProjectEditor"; import { mockData } from "./mocks"; import { DevTool } from "@hookform/devtools"; import { Project, projectSchema } from "./types"; import { yupResolver } from "@hookform/resolvers/yup"; function App() { const form = useForm<Project>({ resolver: yupResolver(projectSchema), defaultValues: mockData, }); return ( <Stack sx={{ width: "100vw", justifyContent: "flex-start", minHeight: "100vh", }} > <AppBar position="static"> <Toolbar> <Typography variant="h6">Ag Grid + React Hook Form</Typography> </Toolbar> </AppBar> <FormProvider {...form}> <ProjectEditor /> </FormProvider> <DevTool control={form.control} /> </Stack> ); } export default App;
And bam, just like that useForm returns everything we’ll need to manage our form, wrapped in a single object which we’ll creatively name… form! Lastly, we’ll make our form object available to any child components by importing RHF’s FormProvider context and passing it our form object.
Building the UI – standard form inputs
Let’s start with something simple to get our feet wet. Create a new file and call it ProjectEditor.tsx. Because we wrapped our app in the FormContextProvider We can call the useFormContext() hook anywhere within our app and access all the goodies in our form via object destructuring. First, lets use the getValues() function to get the project title and render it in the UI. getValues accepts the name of any field and returns the specified field’s value.
import { Paper, Stack, Typography } from "@mui/material"; import { useFormContext } from "react-hook-form"; export interface IStatus { id: string; title: string; } export const ProjectEditor = () => { const { getValues } = useFormContext(); const title = getValues("title"); return ( <Stack sx={{ p: 2 }}> <Paper sx={{ p: 4 }}> <form> <Stack gap={2} p={4} sx={{ background: "#f2f2f2", borderRadius: 2 }}> <Stack direction={"row"} sx={{ justifyContent: "space-between" }}> <Typography variant="h4">{title}</Typography> </Stack> </Stack> </form> </Paper> </Stack> ); };
Not impressed yet? Let’s move on to the inputs. We’ll start with the simplest first, a textfield. Create a components folder inside src and then create a new file and name it TextFieldControlled.tsx. To register an input to the form all we have todo is wrap it in RHF’s Controller component and provide it with control, name and render props.
import { TextField } from "@mui/material"; import { Control, Controller, FieldValues, } from "react-hook-form"; interface IProps { control: Control<FieldValues, any>; name: string; label: string; } export const TextFieldControlled = (props: IProps) => { const { control, name, label } = props; return ( <Controller name={name} control={control} render={({ field }) => ( <TextField {...field} label={label} variant="outlined" /> )} /> ); };
The Controller control prop accepts the control object provided by our useFormContext hook while name accepts the name of the field in our form which the input ties to. Think of the control prop as specifying the form we’re working with, while the name prop points to which field within that form. The render prop is a function that accepts the input component we want to use. Since I’m using MUI in the demo app I’ve specified a TextField and passed it the field props provided by render. We could stop there and have a working reusable text input that can be wired to any field in our form just by providing the field’s name. Let’s take it a few steps further and explore more RHF goodies. By destructuing the dirtyFields and errors objects from useFormContext we can quickly add visual cues to signify if an input is dirty (it’s value differs from the default values used when initialising our form), or display alerts if it’s value does not pass our validation rules. Using bracket notation we can check fields within each object and display the appropriate UI changes if we receive a truthy value. That’s it!
import { TextField } from "@mui/material"; import { Control, Controller, FieldValues, useFormContext, } from "react-hook-form"; interface IProps { control: Control<FieldValues, any>; name: string; label: string; } export const TextFieldControlled = (props: IProps) => { const { name, label } = props; const { control, formState: { dirtyFields, errors }, } = useFormContext(); const isDirty = dirtyFields[name]; const hasError = errors[name]; return ( <Controller name={name} control={control} render={({ field }) => ( <TextField {...field} label={label} variant="outlined" sx={isDirty && dirtyStyle} error={!!hasError} helperText={ (hasError && (hasError.message as string)) || (isDirty && "Dirty") } /> )} /> ); }; const dirtyStyle = { input: { color: "red", }, };
How easy was that!? But wait, what if we were to adapt this pattern to work not just for textfields but for ANY form input. 🤯 Let’s do it. Copy paste the TextfieldControlled.tsx contents into a new empty file called GenericControlled.tsx. First, we’ll replace our Textfield component with a FieldControl var that will be passed in via props. Next, we’ll wrap it in MUI’s FormControl. This will enable us to include labels and helper text for the MUI components that don’t include it out of the box. Finally we’ll add two more properties, an options array which will be used by Select components and a fieldOptions object for any other params that need to be passed in. We could also add an optional prop for a transform function that could be used to transform inputs to a required type, i.e transforming the string values from a number input to number types, or working with objects in Select and Autocomplete options. However, for this article we’re going to skip it. If that is something you’d like to further explore leave a comment and I’d be happy to discuss… who knows it might even warrant a part 3?!
import { Controller, useFormContext } from "react-hook-form"; import { Box, FormControl, FormHelperText, InputLabel } from "@mui/material"; import { ComponentType, ReactElement } from "react"; interface IProps { name: string; label: string; FieldControl: ComponentType<any>; fieldOptions?: any; options?: ReactElement[]; // transform?: (value: any) => any; } export const GenericInputControlled = (props: IProps) => { const { name, options, label, FieldControl, fieldOptions } = props; const { control, formState: { dirtyFields, errors }, } = useFormContext(); const isDirty = dirtyFields[name]; const hasError = errors[name]; return ( <Controller name={name} control={control} render={({ field }) => ( <FormControl fullWidth> {options && <InputLabel id={name}>{label}</InputLabel>} <FieldControl {...field} label={label} variant="outlined" sx={isDirty && dirtyStyle} size="small" fullWidth {...fieldOptions} children={options} error={!!hasError} helperText={ (hasError && (hasError.message as string)) || (isDirty && "Dirty") } /> {options && isDirty && <FormHelperText>Dirty</FormHelperText>} </FormControl> )} /> ); }; const dirtyStyle = { div: { color: "red", }, };
Now, to use our GenericControlled all we need to do is pass the input control we want to use for the UI. Back in TextfieldControlled.tsx replace everything with this beautiful one liner.
import { TextField } from "@mui/material"; import { GenericInputControlled } from "./GenericInputControlled"; interface IProps { name: string; label: string; } export const TextFieldControlled = (props: IProps) => <GenericInputControlled {...props} FieldControl={TextField} />;
Another boom! Let’s keep going, let’s create a Select. Feel free to stop here and give it a crack on your own. I’ll leave the code below for reference.
import { MenuItem, Select } from "@mui/material"; import { Control, FieldValues } from "react-hook-form"; import { IOptionType } from "../types"; import { GenericInputControlled } from "./GenericInputControlled"; import { ReactElement } from "react"; interface IProps<T = unknown> { control: Control<FieldValues, any>; name: string; label: string; options: IOptionType<T>[]; } export const SelectControlled = <T,>(props: IProps<T>) => { const { options } = props; return ( <GenericInputControlled {...props} FieldControl={Select} options={options.map<ReactElement>((option) => ( <MenuItem key={option.id} value={option.id}> {option.title} </MenuItem> ))} /> ); };
It’s not a one liner, but it’s still pretty spiffy. The best part is, if you want to make any changes to your form inputs, say update your styles or implementation details, it’s all kept D.R.Y., contained within your GenericInputControlled.
The Wrap up
We’ve explored how React Hook Form (RHF) can greatly simplify the process of working with forms in React applications. By leveraging RHF’s powerful features and creating a reusable pattern for form components, we have a solid foundation with which we can start to gracefully compose complex forms that we may have otherwise found wild and scary. Furthermore, by contain the majority of our implementation details within our GenericInput components we’re doing our future selves a favour by keeping it DRY and easily maintainable.