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>
)}
/>
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>
)}
/>
);
}
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"
/>
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>
)}
/>
);
}
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,
}));
Then use the component as follows:
<SelectDefault
control={form.control}
name="user_type_id"
label="User type"
title="Select the user type"
values={userTypeOptions}
/>
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)