Haseeb Ahmad

Building Better Forms with shadcn/ui

Learn how to reduce boilerplate and create more maintainable forms using a modified approach to shadcn/ui components

📅
👤Haseeb Ahmad
Topics:
Reactshadcn/uiFormsTypeScript

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!