Using MUI Components with React Hook Form: A Comprehensive Guide to TypeScript Integration
Published at 2024-11-27
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 theregister
method, third-party UI libraries like MUI or Ant Design often use complex wrappers and do not support direct use ofref
. This makesregister
unusable. TheController
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 forController
can be found in the documentation. -
name
: Similar to thename
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 withuseForm
, it acts as a more advanced version ofregister
for complex components, helping RHF bind and register form elements. -
render
: Used for rendering custom components. It takes parameters likefield
,fieldState
, andformState
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 defineonChange
(as shown above). This is because MUI’sAutoComplete
component takesevent
as the first parameter andvalue
as the second parameter for itsonChange
method.
- Note: Besides passing the
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
?
- It takes a generic type
TFieldValues
, which represents the structure of the form object defined when usinguseForm
. FieldValues
is just a type alias forRecord<string, any>
, emphasizing thatTFieldValues
must be an object type.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!