Components

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.

Installation

pnpm dlx shadcn@latest add "https://hasseebmayo.com/r/form-modified.json"

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:

  1. Pre-built components like components.Input for common field types
  2. 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,
};