tools
/react-hook-form-with-zod-client-side-validations-and-shadcn-ui

React Hook Form with Zod Client-Side Validations and @shadcn/ui

Published Feb 9, 2024

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:

copy
npm install @radix-ui/react-label @radix-ui/react-slot react-hook-form @hookform/resolvers zod

yarn:

copy
yarn add @radix-ui/react-label @radix-ui/react-slot react-hook-form @hookform/resolvers zod

bun:

copy
bun 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

copy
1"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

copy
1import * 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

copy
1"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

copy
1const 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

copy
1import { 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

copy
1import { 
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}

Copy Link