Skip to Content
Piplup

Using MUI with React Hook Form

Connect MUI TextField, Select, Autocomplete, Checkbox and DatePicker to react-hook-form without a Controller on every field, with validation and error text.

MUI's inputs are controlled components, so React Hook Form's register doesn't work with them directly. The standard fix is a Controller around every field, which works but repeats the same mapping each time. This guide shows that pattern, then the same form built with @piplup/rhf-adapters, which does the mapping once per component type.

The Controller way

Here is one MUI TextField connected to React Hook Form with a Controller:

<Controller
  name="email"
  control={control}
  rules={{ required: "Enter your email" }}
  render={({ field, fieldState }) => (
    <TextField
      {...field}
      inputRef={field.ref}
      label="Email"
      error={!!fieldState.error}
      helperText={fieldState.error?.message}
    />
  )}
/>

Every field needs the same four decisions: spread the field props, route ref to the inner input so React Hook Form can focus it on error, turn the error into the error flag, and show its message as helper text. Select, Autocomplete, Checkbox and the date pickers each need a slightly different version of that mapping, because their onChange signatures and empty values differ.

The adapter way

The same field with MuiTextFieldElement:

<MuiTextFieldElement
  control={control}
  name="email"
  label="Email"
  required
  messages={{ required: "Enter your email" }}
/>

The element calls useController for you and handles the mapping above. It also accepts required, min, max, minLength, maxLength and pattern as props, and turns them into React Hook Form rules with readable default messages.

Install the core package and the adapters alongside MUI:

npm install @piplup/rhf-core @piplup/rhf-adapters react-hook-form @mui/material @emotion/react @emotion/styled

The installation page lists the supported versions. The date pickers live in a separate subpath with their own installation page.

A complete form

import FormControl from "@mui/material/FormControl";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import {
  MuiButtonElement,
  MuiFormHelperTextElement,
  MuiSelectElement,
  MuiTextFieldElement,
} from "@piplup/rhf-adapters/mui-material";
import { useForm } from "react-hook-form";

type SignUp = { name: string; email: string; role: string };

export function SignUpForm() {
  const { control, handleSubmit } = useForm<SignUp>({
    defaultValues: { name: "", email: "", role: "" },
  });

  return (
    <form noValidate onSubmit={handleSubmit((values) => console.log(values))}>
      <MuiTextFieldElement control={control} name="name" label="Name" required />
      <MuiTextFieldElement control={control} name="email" type="email" label="Email" required />
      <FormControl fullWidth>
        <InputLabel id="role-label">Role</InputLabel>
        <MuiSelectElement control={control} name="role" labelId="role-label" label="Role" required>
          <MenuItem value="viewer">Viewer</MenuItem>
          <MenuItem value="editor">Editor</MenuItem>
        </MuiSelectElement>
        <MuiFormHelperTextElement control={control} name="role" renderOnError />
      </FormControl>
      <MuiButtonElement control={control} type="submit" variant="contained">
        Sign up
      </MuiButtonElement>
    </form>
  );
}

Three details in this form are easy to miss:

  • noValidate on the form. required is passed through to the input, which turns on the browser's own validation popup. noValidate lets React Hook Form's messages show instead.
  • type="email" adds an email pattern with the message "Please enter an email address." You don't need to write the regular expression yourself.
  • MuiFormHelperTextElement under the Select. MUI's Select has no helperText prop, so the Select's error message is rendered by a separate element that reads the same field. With renderOnError it renders nothing until there is an error.

Without useForm: FormContainer

FormContainer creates the form, provides it through context and adds noValidate, so the elements don't need a control prop:

import { MuiButtonElement, MuiTextFieldElement } from "@piplup/rhf-adapters/mui-material";
import { FormContainer } from "@piplup/rhf-core";

export function EmailForm() {
  return (
    <FormContainer defaultValues={{ email: "" }} onSubmit={(values) => console.log(values)}>
      <MuiTextFieldElement name="email" type="email" label="Email" required />
      <MuiButtonElement type="submit" variant="contained">
        Save
      </MuiButtonElement>
    </FormContainer>
  );
}

Validation

Every element accepts these props and turns them into React Hook Form rules:

PropRuleDefault message
requiredrequiredPlease fill out this field.
minLengthminLengthPlease lengthen this text to n characters or more
maxLengthmaxLengthPlease shorten this text to n characters or less.
minminValue must be n or later.
maxmaxValue must be n or earlier.
patternpatternPlease match the requested format.

Override any message with the messages prop, for example messages={{ min: "You must be 18 or older" }}. The min and max defaults are worded for dates, so a custom message reads better on numbers.

For anything else, pass React Hook Form's own rules, such as a validate function. A rule in rules wins over the shorthand prop for the same rule.

Schema validation with zod or yup works the usual way, through a resolver on useForm. Resolver errors land in the same field state, so they show as helper text too. Choose one approach per form: with a resolver, React Hook Form ignores field-level rules, including the ones these props create.

Default and empty values

Each element normalizes the empty value its MUI component expects. Set defaultValues to match, so every field starts controlled:

ElementEmpty valueStored value
MuiTextFieldElement""The string; with type="number", a number or null
MuiSelectElement"", or [] with multipleThe selected value, or an array of them
MuiAutocompleteElementnull, or [] with multipleThe whole option, or an array of options
MuiCheckboxElement[]; unchecking every box stores undefinedAn array of the checked boxes' values
MuiXDatePickerElementnullA date object from your LocalizationProvider adapter

When the stored shape should differ from what the component shows, use transform. For example, the Autocomplete hook demo stores a country code while the input shows the country object. A checkbox can store a boolean with a one-line transform, shown on the MuiCheckboxElement page.

When you need your own component

Every element has a matching hook, such as useMuiTextFieldAdapter, that returns the props instead of rendering. Use the hook when the field needs layout or behaviour the element doesn't offer. For a component library that has no adapter yet, build one on useControllerAdapter from @piplup/rhf-core.

Every MUI element

Next steps

FAQ

Do I need a Controller to use MUI with React Hook Form?

MUI inputs are controlled components, so react-hook-form's register doesn't fit them well and the usual answer is a Controller per field. The Mui*Element components wrap useController for you, so you pass control and name and skip the Controller and its render function.

Why does the browser show its own validation popup instead of my error message?

Elements pass required through to the input, which turns on the browser's built-in validation. Add noValidate to your <form>, or use FormContainer, which adds it for you, and react-hook-form's messages show instead.

Can I use zod or yup instead of the validation props?

Yes. Pass a resolver to useForm as usual. Resolver errors land in the same field state the elements read, so the message still shows as the field's helper text. With a resolver, react-hook-form ignores field-level rules, so put all validation in the schema; required on an element then only adds the asterisk.

How do I change the default error messages for every field?

Each field takes a messages prop for its own rules. To format every error in one place, wrap the form in FormErrorProvider with an errorParser, or pass errorParser to FormContainer. A parser from context takes precedence over one passed to a single field.

Does it work with MUI X date pickers?

Yes. @piplup/rhf-adapters/mui-x-date-pickers has an element for every MUI X picker, plus the date and time fields, calendars and clocks. Picker limits such as minDate and disablePast also become react-hook-form validation, each with its own message.

On this page