TLDR; This page is for my personal usage. This guide is inspired heavily by @shadcn/ui. You can go directly to the page for manual installations and usage there.
-- react-hook-forms docs --
-- make sure you have tailwind installed --
packages installed:
- @radix-ui/react-label
- @radix-ui/react-slot
- react-hook-form
- @hookform/resolvers
- zod
npm:
copynpm install @radix-ui/react-label @radix-ui/react-slot react-hook-form @hookform/resolvers zod
yarn:
copyyarn add @radix-ui/react-label @radix-ui/react-slot react-hook-form @hookform/resolvers zod
bun:
copybun add @radix-ui/react-label @radix-ui/react-slot react-hook-form @hookform/resolvers zod
we use @radix-ui for aria attributes accessibility, react-hook-form for our form's state management, and zod for our client-side validation.
(client-side validation can be optional but I wouldn't suggest)
Install Form components from @shadcn/ui (optional)
https://ui.shadcn.com/docs/components/form#command Suggest to do manual installation, gives you more control of your form ui components.
Below @ui/form.tsx I add a new component to handle uncontrolled field in react-hook-form. I simply changed the FormField into FormFieldControl for controlled field, and create a new FormField component for uncontrolled field.
@ui/label.tsx
copy1"use client" 2 3import * as React from "react" 4import * as LabelPrimitive from "@radix-ui/react-label" 5import { cva, type VariantProps } from "class-variance-authority" 6 7import { cn } from "@/lib/utils" 8 9const labelVariants = cva( 10 "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11) 12 13const Label = React.forwardRef< 14 React.ElementRef<typeof LabelPrimitive.Root>, 15 React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & 16 VariantProps<typeof labelVariants> 17>(({ className, ...props }, ref) => ( 18 <LabelPrimitive.Root 19 ref={ref} 20 className={cn(labelVariants(), className)} 21 {...props} 22 /> 23)) 24Label.displayName = LabelPrimitive.Root.displayName 25 26export { Label } 27
@ui/form.tsx
copy1import * as LabelPrimitive from '@radix-ui/react-label'; 2import { Slot } from '@radix-ui/react-slot'; 3import * as React from 'react'; 4import { 5 Controller, 6 ControllerProps, 7 FieldPath, 8 FieldValues, 9 FormProvider, 10 useFormContext 11} from 'react-hook-form'; 12 13import { Label } from '@/components/@ui/label'; // import from shared @ui component, should also adjust path 14import { cn } from '@/lib/utils'; // should adjust path to grab classnames 15 16const Form = FormProvider; 17 18type FormFieldContextValue< 19 TFieldValues extends FieldValues = FieldValues, 20 TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> 21> = { 22 name: TName; 23}; 24 25const FormFieldContext = React.createContext<FormFieldContextValue>( 26 {} as FormFieldContextValue 27); 28 29const FormFieldControl = < 30 TFieldValues extends FieldValues = FieldValues, 31 TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> 32>({ 33 control = undefined, 34 ...props 35}: ControllerProps<TFieldValues, TName>) => { 36 return ( 37 <FormFieldContext.Provider value={{ name: props.name }}> 38 <Controller {...props} /> 39 </FormFieldContext.Provider> 40 ); 41}; 42 43type FormFieldProps = { 44 children: JSX.Element; 45 name: string; 46}; 47const FormField = ({ children = <></>, name }: FormFieldProps) => { 48 return ( 49 <FormFieldContext.Provider value={{ name }}> 50 {children} 51 </FormFieldContext.Provider> 52 ); 53}; 54 55const useFormField = () => { 56 const fieldContext = React.useContext(FormFieldContext); 57 const itemContext = React.useContext(FormItemContext); 58 const { getFieldState, formState, ...rest } = useFormContext(); 59 60 const fieldState = getFieldState(fieldContext.name, formState); 61 62 if (!fieldContext) { 63 throw new Error('useFormField should be used within <FormField>'); 64 } 65 66 const { id } = itemContext; 67 68 return { 69 id, 70 name: fieldContext.name, 71 formItemId: `${id}-form-item`, 72 formDescriptionId: `${id}-form-item-description`, 73 formMessageId: `${id}-form-item-message`, 74 ...fieldState, 75 ...rest 76 }; 77}; 78 79type FormItemContextValue = { 80 id: string; 81}; 82 83const FormItemContext = React.createContext<FormItemContextValue>( 84 {} as FormItemContextValue 85); 86 87const FormItem = React.forwardRef< 88 HTMLDivElement, 89 React.HTMLAttributes<HTMLDivElement> 90>(({ className, ...props }, ref) => { 91 const id = React.useId(); 92 93 return ( 94 <FormItemContext.Provider value={{ id }}> 95 <div ref={ref} className={cn('space-y-2', className)} {...props} /> 96 </FormItemContext.Provider> 97 ); 98}); 99FormItem.displayName = 'FormItem'; 100 101const FormLabel = React.forwardRef< 102 React.ElementRef<typeof LabelPrimitive.Root>, 103 React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> 104>(({ className, ...props }, ref) => { 105 const { error, formItemId } = useFormField(); 106 107 return ( 108 <Label 109 ref={ref} 110 className={cn(error && 'text-destructive', className)} 111 htmlFor={formItemId} 112 {...props} 113 /> 114 ); 115}); 116FormLabel.displayName = 'FormLabel'; 117 118const FormControl = React.forwardRef< 119 React.ElementRef<typeof Slot>, 120 React.ComponentPropsWithoutRef<typeof Slot> 121>(({ ...props }, ref) => { 122 const { error, formItemId, formDescriptionId, formMessageId } = 123 useFormField(); 124 125 return ( 126 <Slot 127 ref={ref} 128 id={formItemId} 129 aria-describedby={ 130 !error 131 ? `${formDescriptionId}` 132 : `${formDescriptionId} ${formMessageId}` 133 } 134 aria-invalid={!!error} 135 {...props} 136 /> 137 ); 138}); 139FormControl.displayName = 'FormControl'; 140 141const FormDescription = React.forwardRef< 142 HTMLParagraphElement, 143 React.HTMLAttributes<HTMLParagraphElement> 144>(({ className, ...props }, ref) => { 145 const { formDescriptionId } = useFormField(); 146 147 return ( 148 <p 149 ref={ref} 150 id={formDescriptionId} 151 className={cn('text-muted-foreground text-sm', className)} 152 {...props} 153 /> 154 ); 155}); 156FormDescription.displayName = 'FormDescription'; 157 158const FormMessage = React.forwardRef< 159 HTMLParagraphElement, 160 React.HTMLAttributes<HTMLParagraphElement> 161>(({ className, children, ...props }, ref) => { 162 const { error, formMessageId } = useFormField(); 163 const body = error ? String(error?.message) : children; 164 165 if (!body) { 166 return null; 167 } 168 169 return ( 170 <p 171 ref={ref} 172 id={formMessageId} 173 className={cn('text-destructive text-sm font-medium', className)} 174 {...props} 175 > 176 {body} 177 </p> 178 ); 179}); 180FormMessage.displayName = 'FormMessage'; 181 182export { 183 Form, 184 FormControl, 185 FormDescription, 186 FormField, //for uncontrolled field 187 FormFieldControl, // for controlled field 188 FormItem, 189 FormLabel, 190 FormMessage, 191 useFormField 192}; 193
1. Schema and Form validation
create zod schema and attach to useForm
copy1"use client" 2 3import { z } from "zod" 4import { zodResolver } from "@hookform/resolvers/zod" 5import { useForm } from "react-hook-form" 6 7const formSchema = z.object({ 8 username: z.string().min(2).max(50), 9}) 10 11type MyFormSchema = z.infer<typeof formSchema> 12 13export default function MyFormComponent() { 14 const form = useForm<MyFormSchema>({ 15 resolver: zodResolver(formSchema), 16 defaultValues: { username: "", }, 17 }) 18 19 const onSubmit = (data: MyFormSchema) => { 20 // do form submission here and call api 21 console.log(data) 22 } 23 24 return ( 25 ... 26 ) 27} 28
2. Build Form
Uncontrolled Form:
At its simplest form without using @shadcn/ui at all
copy1const MyFormComponent = () => { 2 ... 3 4 return ( 5 <form onSubmit={form.handleSubmit(onSubmit)}> 6 <div> 7 <label for="username" >Input:</label> 8 <input type="text" id="username" {...register('username')}/> 9 </div> 10 <input type="submit" /> 11 </form> 12 ) 13} 14
with @shadcn/ui
copy1import { Form, FormField, useFormField } from './@ui/form'; 2... 3 4const MyFormComponent = () => { 5 ... 6 7 return ( 8 <Form {...form}> 9 <form onSubmit={form.handleSubmit(onSubmit)}> 10 <FormField name="username"> 11 <MyCustomField /> 12 </FormField> 13 14 <input type="submit" value="submit" /> 15 </form> 16 </Form> 17 ) 18} 19 20// create custom field that subscribe to the <FieldItem> context 21const MyCustomField = () => { 22 const { register, formItemId, name } = useFormField(); 23 24 return ( 25 <div> 26 <label htmlFor={formItemId}>Input:</label> 27 <input type="text" id={formItemId} {...register(name)} /> 28 </div> 29 ); 30};
Controlled Form:
using @shadcn/ui
copy1import { 2 Form, 3 FormFieldControl, 4 FormItem, 5 FormLabel, 6 FormControl, 7 FormDescription, 8 FormMessage 9} from './@ui/form'; 10... 11 12const MyFormComponent = () => { 13 return ( 14 <Form {...form}> 15 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> 16 <FormFieldControl 17 control={form.control} 18 name="username" 19 render={({ field }) => ( 20 <FormItem> 21 <FormLabel>Username</FormLabel> 22 <FormControl> 23 <input placeholder="shadcn" {...field} /> 24 </FormControl> 25 <FormDescription>This is your public display name.</FormDescription> 26 <FormMessage /> 27 </FormItem> 28 )} 29 /> 30 <Button type="submit">Submit</Button> 31 </form> 32 </Form> 33 ) 34}