Using MUI Components with React Hook Form: A Comprehensive Guide to TypeScript Integration

Published at 2024-11-27

cover_image

There are many examples online about using MUI components with React Hook Form, but when a form involves many customized AutoComplete components, creating a shared component becomes necessary. When developing a shared component, several TypeScript type issues need to be addressed, and depending on the requirements, some adjustments in props passing may also be required.

The basic usage of React Hook Form (RHF) with input fields will not be elaborated here, as the official documentation provides detailed explanations.

This post is lengthy due to the complexity of typing. If you are looking for the conclusion, you can find the code here.

Note: React Hook Form will be abbreviated as RHF.

Today, we’ll demonstrate the following requirements for an MUI AutoComplete component:

  • Bind it to RHF.
  • Enable text search via TextField.

Ignoring Typings for Now: Basic RHF Binding with MUI AutoComplete

Here’s what a basic MUI AutoComplete component bound to RHF would look like, ignoring TypeScript typings for now:

1export default function CustomAutoComplete({ 2 control, name, label, options, 3}: Props) { 4 return ( 5 <Controller 6 name={name} 7 control={control} 8 render={({ field }) => ( 9 <Autocomplete 10 {...field} 11 options={options} 12 value={field.value} 13 onChange={(_, newValue) => { 14 field.onChange(newValue); 15 }} 16 renderInput={(params) => ( 17 <TextField 18 {...params} 19 label={label} 20 variant="outlined" 21 /> 22 )} 23 /> 24 )} 25 /> 26 ); 27}
  • Controller: A wrapper provided by RHF. While most basic input elements (such as <input> and <select>) can be directly registered using the register method, third-party UI libraries like MUI or Ant Design often use complex wrappers and do not support direct use of ref. This makes register unusable. The Controller acts as a bridge between the form and the component, allowing form binding and behavior control even with complex wrapped UI components. More usage details for Controller can be found in the documentation.

  • name: Similar to the name attribute in traditional HTML inputs, it serves as a unique identifier for form elements. Using duplicate names for different form components may cause issues.

  • control: Used with useForm, it acts as a more advanced version of register for complex components, helping RHF bind and register form elements.

  • render: Used for rendering custom components. It takes parameters like field, fieldState, and formState for customization. You can pass the parameters you need according to your requirements. (Refer to the documentation.)

    • Note: Besides passing the value to make the component controlled, you also need to explicitly define onChange (as shown above). This is because MUI’s AutoComplete component takes event as the first parameter and value as the second parameter for its onChange method.

For example, if you want to display an error message below the component when there’s a validation error, you can use the fieldState.error.message property:

1<Controller 2 name={name} 3 control={control} 4 render={({ field, fieldState }) => ( 5 <> 6 <Autocomplete 7 {...field} 8 options={options} 9 renderInput={(params) => ( 10 <TextField 11 {...params} 12 variant="outlined" 13 /> 14 )} 15 /> 16 {fieldState.error && ( 17 <Typography color="error">{fieldState.error.message}</Typography> 18 )} 19 </> 20 )} 21/>

This way, when RHF detects a validation error, the error message will be conditionally rendered below the component.

In this example, the required props to pass are as follows:

  • For RHF binding:
    • name
    • control
  • For MUI AutoComplete:
    • options (provides options for the user to select)

These three props form the basic version of the component. Later, we’ll add more variations.

Understanding name Typings in RHF

The official documentation defines the type of name as follows:

1export type FieldPath<TFieldValues extends FieldValues> = Path<TFieldValues>;

This looks complicated, so let’s break it down step by step.

What is FieldPath?
  1. It takes a generic type TFieldValues, which represents the structure of the form object defined when using useForm.
  2. FieldValues is just a type alias for Record<string, any>, emphasizing that TFieldValues must be an object type.
  3. Path<TFieldValues>: Path is a recursive type defined by RHF that parses and validates deep paths within an object.

For example:

1// Assume your form structure looks like this: 2type FormValues = { 3 username: string; 4 profile: { 5 firstName: string; 6 lastName: string; 7 }; 8}; 9 10const { control } = useForm<FormValues>();

In this case, valid name paths include:

  • username
  • profile.firstName
  • profile.lastName

If you pass an invalid path, like profile.whatever, TypeScript will throw an error due to type mismatch.

Simplifying further, you can think of it like this:

1type Name<Form extends FieldValues> = Path<Form>;

Now that we understand that the type of name is essentially Path<T>, defining the props becomes much simpler:

1// CustomAutoComplete.tsx 2 3import { 4 type FieldValues, type Path, 5} from 'react-hook-form'; 6 7type Props<T extends FieldValues> = { 8 name: Path<T>; 9};

With this generic parameter T, we can adapt to various form structures.

Defining control Typing

Next, let’s define the type for control. This is relatively simple:

1import { 2 type Control, Controller, type FieldValues, type Path, 3} from 'react-hook-form'; 4 5type Props<T extends FieldValues> = { 6 name: Path<T>; 7 control: Control<T>; 8};

Now, we’ve completed the TypeScript definitions for props related to RHF.

When using the component, remember to pass <T> to ensure type checking works properly:

1type Props<T extends FieldValues> = { 2 name: Path<T>; 3 control: Control<T>; 4}; 5 6export default function CustomAutoComplete<T extends FieldValues>({ 7 control, name, 8}: Props<T>) { 9 10// your component code...

Diving into MUI AutoComplete Typings

The options prop is specific to the MUI AutoComplete component, so let’s define its prop type for seamless integration.

If you look at the AutoComplete type definition, it’s quite extensive:

1export default function Autocomplete< 2 Value, 3 Multiple extends boolean | undefined = false, 4 DisableClearable extends boolean | undefined = false, 5 FreeSolo extends boolean | undefined = false, 6 ChipComponent extends React.ElementType = ChipTypeMap['defaultComponent'], 7>( 8 props: AutocompleteProps<Value, Multiple, DisableClearable, FreeSolo, ChipComponent>, 9): JSX.Element;

You could copy and paste this into your component definition, but there’s a simpler way.

Using the Parameters Utility Type

TypeScript provides a utility type called Parameters<T>, where T must be a function type ((...args: any) => any). It extracts the parameters of the function as a tuple. (Documentation)

If you don’t pass a function type, it will return never.

Example:

1type MyFunction = (a: string, b: number) => boolean; 2type Params = Parameters<MyFunction>; // [string, number]

You can also use it for higher-order functions:

1function add(a: number, b: number): number { 2 return a + b; 3} 4 5function multiply(a: number, b: number): number { 6 return a * b; 7} 8 9function execute( 10 fn: (...args: Parameters<typeof add>) => number, 11 ...args: Parameters<typeof add> 12) { 13 return fn(...args); 14} 15 16console.log(execute(add, 2, 3)); // 5 17console.log(execute(multiply, 2, 3)); // 6

In our component, Parameters helps us extract the prop types without having to copy all the generic types:

1type Props<T extends FieldValues> = { 2 name: Path<T>; 3 control: Control<T>; 4} & Parameters<typeof Autocomplete>[0]; 5// Since it's a tuple, we use `[0]` to extract the first element, which contains the prop types.

However, AutoComplete still needs to know the values of its generic parameters, so we’ll need to pass them explicitly.

For this, there are five parameters to pass:

  • Value (customizable)
  • Multiple (boolean)
  • DisableClearable (boolean)
  • FreeSolo (boolean)
  • ChipComponent (React.ElementType)

Defining Generic Prop Types for AutoComplete

Here’s how we’ll define the types step by step:

Step 1: Pass all generic parameters, setting Value as a generic.

1type MuiAutoCompleteProps<K> = Parameters<typeof Autocomplete< 2K, boolean, boolean, boolean, React.ElementType>>[0];

Step 2: Define a fixed type for Value since it will always have the same shape.

1type Option = { 2 label: string; 3 value: string; 4}; 5 6type MuiAutoCompleteProps<K = Option> = Parameters<typeof Autocomplete< 7K, boolean, boolean, boolean, React.ElementType>>[0];

Step 3: Since renderInput will be hardcoded in the component, we’ll need to Omit it from the props before merging it with Props to avoid duplication.

1type MuiAutoCompleteProps<K = Option> = Parameters<typeof Autocomplete< 2K, boolean, boolean, boolean, React.ElementType>>[0]; 3 4type Props<T extends FieldValues> = { 5 name: Path<T>; 6 control: Control<T>; 7 label: React.ReactNode; 8} & Omit<MuiAutoCompleteProps, 'renderInput'>;

Final Implementation of the Component

Here’s what the component looks like:

1import { Autocomplete, TextField, Typography } from '@mui/material'; 2import { 3 type Control, Controller, type FieldValues, type Path, 4} from 'react-hook-form'; 5 6type Option = { 7 label: string; 8 value: string; 9}; 10 11type MuiAutoCompleteProps<K = Option> = Parameters<typeof Autocomplete< 12K, boolean, boolean, boolean, React.ElementType>>[0]; 13 14type Props<T extends FieldValues> = { 15 name: Path<T>; 16 control: Control<T>; 17} & Omit<MuiAutoCompleteProps, 'renderInput'>; 18 19export default function CustomAutoComplete<T extends FieldValues>({ 20 control, name, ...autocompleteProps 21}: Props<T>) { 22 return ( 23 <Controller 24 name={name} 25 control={control} 26 render={({ field, fieldState }) => ( 27 <> 28 <Autocomplete 29 {...field} 30 value={field.value} 31 onChange={(_, newValue) => { 32 field.onChange(newValue); 33 }} 34 renderInput={(params) => ( 35 <TextField 36 {...params} 37 variant="outlined" 38 /> 39 )} 40 {...autocompleteProps} 41 /> 42 {fieldState.error && ( 43 <Typography color="error">{fieldState.error.message}</Typography> 44 )} 45 </> 46 )} 47 /> 48 ); 49}

Adding Customizable Props

Here’s the final version with additional customizable props:

1import React from 'react'; 2import { 3 Controller, 4 type Path, 5 type FieldValues, 6 type Control, 7 type UseControllerProps, 8} from 'react-hook-form'; 9import { 10 type TextFieldProps, 11 Autocomplete as MuiAutoComplete, 12 Stack, 13 TextField, 14 Typography, 15} from '@mui/material'; 16 17type Option = { 18 label: string; 19 value: string; 20}; 21 22type MuiAutoCompleteProps<K = Option> = Parameters< 23 typeof MuiAutoComplete<K, boolean, boolean, boolean, React.ElementType> 24>[0]; 25 26type Props<T extends FieldValues> = { 27 name: Path<T>; 28 control: Control<T>; 29 label?: string; 30 placeholder?: string; 31 controllerProps?: Omit<UseControllerProps<T>, 'name' | 'control'>; 32 textFieldProps?: TextFieldProps; 33} & Omit<MuiAutoCompleteProps, 'renderInput'>; 34 35const AutoComplete = <T extends FieldValues>({ 36 name, 37 control, 38 options, 39 multiple = false, 40 label = '', 41 placeholder = '', 42 controllerProps = {}, 43 textFieldProps = {}, 44 ...autoCompleteProps 45}: Props<T>) => ( 46 <Controller 47 name={name} 48 control={control} 49 {...controllerProps} 50 render={({ field: { onChange, value }, fieldState: { error } }) => ( 51 <MuiAutoComplete 52 multiple={multiple} 53 disableCloseOnSelect={multiple} 54 filterSelectedOptions={multiple} 55 options={options} 56 value={value} 57 onChange={(_, newValue) => { 58 onChange(newValue); 59 }} 60 renderInput={(params) => ( 61 <TextField 62 {...params} 63 label={label} 64 placeholder={placeholder} 65 error={!!error} 66 helperText={error?.message} 67 {...textFieldProps} 68 /> 69 )} 70 {...autoCompleteProps} 71 /> 72 )} 73 /> 74); 75 76export default AutoComplete;

Additional settings (e.g., multiple, ChipProps, etc.) can be found here. Solving the typing issues alone took me nearly a day. I hope this post helps others working on shared components!