DEV Community

Cover image for 🧩 Reusable Form Components with Shadcn/UI: Cleaner Code, Better Experience
Micael Miranda Inácio
Micael Miranda Inácio

Posted on

2

🧩 Reusable Form Components with Shadcn/UI: Cleaner Code, Better Experience

If you have ever used the shadcn/ui components with React Hook Form, you might have noticed that your form files can quickly become bloated. Although shadcn/ui offers great control over each part of a component, this flexibility often results in a giant file filled with repetitive markup. To solve this, I devised a way to create “default” versions of each form component that still allow for customization.

So, let's take a look at the standard input component implemented inside a form:

<FormField
  control={form.control}
  name="username"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Username</FormLabel>
      <FormControl>
        <Input placeholder="shadcn" {...field} />
      </FormControl>
      <FormDescription>This is your public display name.</FormDescription>
      <FormMessage />
    </FormItem>
  )}
/>
Enter fullscreen mode Exit fullscreen mode

Clearly, there’s too much boilerplate around a simple <Input /> intended to control a form field. Leveraging one of React’s greatest features—component composition—we can create a new component that encapsulates this markup, yielding a compact, customizable component.

Creating a Reusable Input Component

// src/components/form/input-default.tsx
import {
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";

interface InputDefaultProps {
  control: any;
  name: string;
  label?: string;
  placeholder: string;
  type?: string;
  description?: string;
  className?: string;
  inputClassname?: string;
}

export default function InputDefault({
  control,
  name,
  label,
  placeholder,
  type = "text",
  description,
  className,
  inputClassname,
}: InputDefaultProps) {
  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem className={className}>
          {label && <FormLabel>{label}</FormLabel>}
          <FormControl>
            <Input
              className={inputClassname}
              type={type}
              placeholder={placeholder}
              {...field}
            />
          </FormControl>
          <FormMessage />
          {description && <FormDescription>{description}</FormDescription>}
        </FormItem>
      )}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

In this component, some props (like control and name) are essential, while others (such as label, description, and style properties) are optional to allow for customization. This design lets you pass additional props—such as onChange events or disabled states—while keeping the component call simple. For example:

// inside a form tag
<InputDefault
  control={form.control}
  name="username"
  label="Username"
  placeholder="Enter your username"
/>
Enter fullscreen mode Exit fullscreen mode

In my projects, I created a form folder inside the components directory to store these pre-styled form components. For each new component—checkbox, select, date picker, text area, etc.—I create a separate file.

Customizing a Select Component

Some components require a bit more complexity. For example, a select component:

import {
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";

interface SelectDefaultProps {
  control: any;
  name: string;
  title?: string;
  label: string;
  className?: string;
  values: {
    value: any;
    label: any;
  }[];
}

export default function SelectDefault({
  control,
  name,
  title,
  label,
  values,
  className,
}: SelectDefaultProps) {
  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem className={cn("flex flex-col", className)}>
          <FormLabel>{title}</FormLabel>
          <Select onValueChange={field.onChange} defaultValue={field.value}>
            <FormControl>
              <SelectTrigger className="hover:bg-accent outline-none data-[placeholder]:text-muted-foreground hover:text-accent-foreground">
                <SelectValue placeholder={label} />
              </SelectTrigger>
            </FormControl>
            <SelectContent>
              {values.map((item, index) => (
                <SelectItem key={index} value={item.value}>
                  {item.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
          <FormMessage />
        </FormItem>
      )}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Because we can’t predict how the select items will be arranged, this component is designed to be generic. It accepts an array called values—each with a value and a label—and maps over it to generate the select items. Consequently, the data may need to be formatted before being passed to the component. For example:

const userTypeOptions = userTypes.map((userType) => ({
  label: userType.type,
  value: userType.id,
}));
Enter fullscreen mode Exit fullscreen mode

Then use the component as follows:

<SelectDefault
  control={form.control}
  name="user_type_id"
  label="User type"
  title="Select the user type"
  values={userTypeOptions}
/>
Enter fullscreen mode Exit fullscreen mode

Even with this added complexity, this approach is much better than embedding all the select component tags directly in your form code.

With these organized components and forms, your application will not only look better but will also be much easier to maintain. Let me know in the comments if you implement something similar—or even better—and share your thoughts on improving code organization with this stack.

Top comments (0)