Form
Modified form with less boilerplate code
Problem
Creating forms with shadcn/ui requires significant boilerplate code. Every field needs manual wiring of FormItem, FormLabel, FormControl, and FormMessage components, making form development repetitive and verbose.
Here's the typical approach:
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
This pattern repeats for every field in a form, which becomes tedious when working with multiple fields.
Solution
I've created a modified version that still uses the original Form component from shadcn/ui, but with a much simpler API. It accepts a schema and provides an onSubmit
handler for managing form submission. This reduces boilerplate and lets you focus on defining your fields.
The form is wrapped in a fieldset, which can be disabled by passing a disabled
prop. It also supports defaultValues
, which are automatically inferred from the schema—making it easy to prefill the form with strongly typed data.
If you want to pass additional props to the underlying form, use the formProps
prop. Similarly, any props for the fieldset can be passed via fieldsetProps
.
Note: For this to work you should have the specific version of react-hook-form@7.56.2 and "@hookform/resolvers@5.1.0"
Installation
Usage
Basic Form
"use client"
import z from "zod"
import { Button } from "@/registry/ui/button"
import Form from "@/registry/ui/form-modified"
import { Textarea } from "@/registry/ui/textarea"
const schema = z.object({
name: z.string(),
type: z.string(),
description: z.string().optional(),
})
type SchemaType = z.infer<typeof schema>
export function FormDemo() {
return (
<Form
schema={schema}
onSubmit={(data: SchemaType) => {
console.log("Form submitted with data:", data)
}}
>
{({ components }) => (
<>
<components.Input
name="name"
label="Name"
description="Enter your name"
/>
<components.Input
name="type"
label="Type"
description="Enter the type"
/>
<components.Field name="description">
{(fields) => (
<Textarea {...fields} placeholder="Enter a description" />
)}
</components.Field>
<Button>Submit</Button>
</>
)}
</Form>
)
}
Using FormFieldWrapper
The modified form provides two main options for field composition:
- Pre-built components like
components.Input
for common field types - Field wrapper (
components.Field
) for custom components
The components.Field
wrapper works like a Controller from react-hook-form, but automatically receives the form control without needing to pass it manually.
Accessing Form Methods
The modified form also provides access to form methods, which can be destructured for advanced use cases:
<Form
schema={schema}
onSubmit={(data: SchemaType) => {
console.log("Form submitted with data:", data)
}}
>
{({ components, methods }) => (
<>
<components.Input
name="name"
label="Name"
description="Enter your name"
/>
<components.Input
name="type"
label="Type"
description="Enter the type"
/>
<components.Field name="description">
{(fields) => (
<Textarea {...fields} placeholder="Enter a description" />
)}
</components.Field>
<Button type="button" onClick={() => methods.reset()}>
Reset
</Button>
<Button>Submit</Button>
</>
)}
</Form>
You can use the methods
object to access functions like reset
, setValue
, and other react-hook-form methods directly.
Types Error inside of Form component.
If you encounter types errors inside the Form component, change your form component with the below code:
'use client';
import type { Label as LabelPrimitive } from 'radix-ui';
import type {
ControllerProps,
FieldPath,
FieldValues,
} from 'react-hook-form';
import { Slot as SlotPrimitive } from 'radix-ui';
import * as React from 'react';
import {
Controller,
FormProvider,
useFormContext,
useFormState,
} from 'react-hook-form';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
TTransformedValues extends FieldValues = FieldValues,
>({
...props
}: ControllerProps<TFieldValues, TName, TTransformedValues>) => {
return (
<FormFieldContext value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext>
);
};
const useFormField = () => {
const fieldContext = React.use(FormFieldContext);
const itemContext = React.use(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId();
return (
<FormItemContext value={{ id }}>
<div
data-slot="form-item"
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof SlotPrimitive.Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<SlotPrimitive.Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm font-medium', className)}
{...props}
>
{body}
</p>
);
}
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
};