Building Better Forms with shadcn/ui
Learn how to reduce boilerplate and create more maintainable forms using a modified approach to shadcn/ui components
Building Better Forms with shadcn/ui
Forms are a crucial part of any web application, but they can quickly become verbose and repetitive when using component libraries. In this post, I'll show you how I've simplified form creation with shadcn/ui while maintaining all the benefits of type safety and validation.
The Problem
When building forms with shadcn/ui, you typically end up with a lot of boilerplate code. For each field, you need to manually wire up FormField
, FormItem
, FormLabel
, FormControl
, and FormMessage
components:
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
This pattern repeats for every single field, making forms with many inputs quite verbose and hard to maintain.
The Solution
I created a modified Form component that reduces this boilerplate significantly. Instead of repeating the same pattern, you can now define forms like this:
<Form
schema={schema}
onSubmit={(data) => console.log(data)}
>
{({ components }) => (
<>
<components.Input
name="email"
label="Email"
description="Enter your email address"
/>
<components.Input
name="password"
label="Password"
type="password"
/>
<Button>Submit</Button>
</>
)}
</Form>
Key Benefits
1. Reduced Boilerplate
No more repetitive FormField
wrappers for every input.
2. Type Safety
Full TypeScript support with schema inference.
3. Flexibility
Still supports custom components through the Field
wrapper.
4. Familiar API
Builds on top of the existing shadcn/ui Form component.
Implementation Details
The modified form component accepts a Zod schema and automatically handles:
- Form state management
- Validation
- Error display
- Type inference
You can also access form methods for advanced use cases:
{({ components, methods }) => (
<>
<components.Input name="name" label="Name" />
<Button type="button" onClick={() => methods.reset()}>
Reset
</Button>
</>
)}
Working with Custom Components
For components not included in the pre-built set, you can use the Field
wrapper:
<components.Field name="description">
{(fieldProps) => (
<Textarea
{...fieldProps}
placeholder="Enter a description"
rows={4}
/>
)}
</components.Field>
This gives you full control over the component while maintaining form integration.
Form Configuration
The modified form supports additional configuration options:
<Form
schema={schema}
defaultValues={{ name: "John Doe" }}
disabled={isLoading}
onSubmit={handleSubmit}
formProps={{ className: "space-y-4" }}
fieldsetProps={{ disabled: isSubmitting }}
>
{/* Form content */}
</Form>
Validation and Error Handling
Validation is handled automatically through your Zod schema. Complex validation rules are supported:
const schema = z.object({
email: z.string().email("Invalid email address"),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain uppercase letter"),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"]
})
Real-World Example
Here's a complete example of a user registration form that demonstrates the power of this approach:
"use client"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import Form from "@/components/ui/form-modified"
const registrationSchema = z.object({
firstName: z.string().min(2, "First name must be at least 2 characters"),
lastName: z.string().min(2, "Last name must be at least 2 characters"),
email: z.string().email("Please enter a valid email address"),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain at least one uppercase letter")
.regex(/[a-z]/, "Must contain at least one lowercase letter")
.regex(/[0-9]/, "Must contain at least one number"),
confirmPassword: z.string(),
agreeToTerms: z.boolean().refine(val => val === true, {
message: "You must agree to the terms and conditions"
})
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"]
})
export function RegistrationForm() {
const handleSubmit = async (data: z.infer<typeof registrationSchema>) => {
try {
// Handle registration logic
console.log("Registration data:", data)
} catch (error) {
console.error("Registration failed:", error)
}
}
return (
<div className="max-w-md mx-auto p-6">
<h2 className="text-2xl font-bold mb-6">Create Account</h2>
<Form
schema={registrationSchema}
onSubmit={handleSubmit}
formProps={{ className: "space-y-4" }}
>
{({ components, methods }) => (
<>
<div className="grid grid-cols-2 gap-4">
<components.Input
name="firstName"
label="First Name"
placeholder="John"
/>
<components.Input
name="lastName"
label="Last Name"
placeholder="Doe"
/>
</div>
<components.Input
name="email"
label="Email"
type="email"
placeholder="john@example.com"
/>
<components.Input
name="password"
label="Password"
type="password"
description="Must be at least 8 characters with uppercase, lowercase, and number"
/>
<components.Input
name="confirmPassword"
label="Confirm Password"
type="password"
/>
<components.Checkbox
name="agreeToTerms"
label="I agree to the Terms and Conditions"
/>
<div className="flex gap-3">
<Button type="submit" className="flex-1">
Create Account
</Button>
<Button
type="button"
variant="outline"
onClick={() => methods.reset()}
>
Reset
</Button>
</div>
</>
)}
</Form>
</div>
)
}
This example shows how clean and readable forms can be with the modified approach, even with complex validation rules and multiple field types.
Migration Guide
If you're currently using the standard shadcn/ui forms, migrating is straightforward:
Before (Standard shadcn/ui)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { email: "" }
})
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</FormProvider>
)
After (Modified Form)
return (
<Form schema={formSchema} onSubmit={onSubmit} defaultValues={{ email: "" }}>
{({ components }) => (
<components.Input name="email" label="Email" />
)}
</Form>
)
The reduction in code is significant, especially for forms with many fields.
Performance Considerations
The modified form component maintains the same performance characteristics as the original shadcn/ui implementation:
- No unnecessary re-renders - Uses React Hook Form's optimized field subscription
- Lazy validation - Validation only runs when needed
- Small bundle size - Builds on existing dependencies
- TypeScript optimized - Full type inference with no runtime overhead
Conclusion
This approach has significantly improved my development experience when building forms. It maintains all the benefits of shadcn/ui while reducing the amount of code needed for common form patterns.
You can find the complete implementation in the components section of this site.
What's your experience with form libraries? Let me know on Twitter!