BACK TO Articles

React forms, the easy way

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.

Looking for something specific?

Search our Archive to find content that piques your interest.
SEARCH

Recents Posts

December 13, 2024
Bring seasonal sparkle to your website with this simple script
By Tim McVinish ‘Tis the season for festive websites Well, it’s the holiday season. While our homes, shopping centres, and everything else around us gets a sprinkle of holiday magic, websites are often overlooked. So today we’re going to explore creating a simple script that can be used to add a little festive flare to…
Read more
December 12, 2024
Checklist: Preparing your loved ones for a safe and cyber-secure Christmas 
By Rio Heral Caring for your loved ones during the festive season involves more than just ensuring they’re warm and well-fed. Cybercriminals ramp up their phishing scams at Christmas, targeting those who may not be as tech-savvy. Use this checklist to help your loved one stay safe online and enjoy a worry-free holiday season.  1.…
Read more
December 10, 2024
Build an engaging holiday advent calendar with Power Platform: a reusable marketing framework
By Michael Chu The holiday season is perfect for engaging your audience with interactive and meaningful campaigns. Using Microsoft Power Platform, you can create an advent calendar experience that not only brings festive cheer but also gathers valuable insights and builds relationships with your customers. Here's how to architect a reusable calendar solution to support…
Read more
December 5, 2024
Your guide to setting up Secret Santa with SharePoint and Power Automate
By Madison Steele Secret Santa exchanges are a fun way to spread holiday cheer, but managing them can be a logistical nightmare, especially in the workplace. Fortunately, with SharePoint and Power Automate, you can streamline the entire process. From collecting wish lists to tracking gift status, this guide will help you set up a Secret…
Read more