Skip to main content
Back to Blog
Tutorials
4 min read
November 7, 2024

How to Build Reusable Form Components in React

Build a library of reusable form components in React. Text inputs, selects, checkboxes, and radio groups with React Hook Form and Zod.

Ryel Banfield

Founder & Lead Developer

Stop rewriting form logic. Build a consistent form component library that handles validation, errors, and accessibility.

Step 1: Install Dependencies

pnpm add react-hook-form @hookform/resolvers zod

Step 2: Form Field Wrapper

// components/form/FormField.tsx
import { type ReactNode } from "react";

interface FormFieldProps {
  label: string;
  error?: string;
  description?: string;
  required?: boolean;
  children: ReactNode;
}

export function FormField({
  label,
  error,
  description,
  required,
  children,
}: FormFieldProps) {
  return (
    <div className="space-y-1.5">
      <label className="block text-sm font-medium text-gray-900 dark:text-white">
        {label}
        {required && <span className="ml-0.5 text-red-500">*</span>}
      </label>
      {description && (
        <p className="text-xs text-gray-500">{description}</p>
      )}
      {children}
      {error && (
        <p className="text-xs text-red-600" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

Step 3: Text Input Component

// components/form/TextInput.tsx
import { forwardRef, type InputHTMLAttributes } from "react";
import { FormField } from "./FormField";

interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
  description?: string;
}

export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
  ({ label, error, description, required, className, ...props }, ref) => {
    return (
      <FormField
        label={label}
        error={error}
        description={description}
        required={required}
      >
        <input
          ref={ref}
          aria-invalid={!!error}
          className={`w-full rounded-lg border px-3 py-2 text-sm outline-none transition focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 ${
            error
              ? "border-red-300 focus:ring-red-500"
              : "border-gray-300 dark:border-gray-600"
          } ${className}`}
          {...props}
        />
      </FormField>
    );
  }
);
TextInput.displayName = "TextInput";

Step 4: Textarea Component

// components/form/TextArea.tsx
import { forwardRef, type TextareaHTMLAttributes } from "react";
import { FormField } from "./FormField";

interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
  label: string;
  error?: string;
  description?: string;
}

export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
  ({ label, error, description, required, className, ...props }, ref) => {
    return (
      <FormField
        label={label}
        error={error}
        description={description}
        required={required}
      >
        <textarea
          ref={ref}
          aria-invalid={!!error}
          rows={4}
          className={`w-full rounded-lg border px-3 py-2 text-sm outline-none transition focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 ${
            error ? "border-red-300" : "border-gray-300 dark:border-gray-600"
          } ${className}`}
          {...props}
        />
      </FormField>
    );
  }
);
TextArea.displayName = "TextArea";

Step 5: Select Component

// components/form/Select.tsx
import { forwardRef, type SelectHTMLAttributes } from "react";
import { FormField } from "./FormField";

interface Option {
  value: string;
  label: string;
}

interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
  label: string;
  options: Option[];
  error?: string;
  description?: string;
  placeholder?: string;
}

export const Select = forwardRef<HTMLSelectElement, SelectProps>(
  (
    { label, options, error, description, required, placeholder, ...props },
    ref
  ) => {
    return (
      <FormField
        label={label}
        error={error}
        description={description}
        required={required}
      >
        <select
          ref={ref}
          aria-invalid={!!error}
          className={`w-full rounded-lg border px-3 py-2 text-sm outline-none transition focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 ${
            error ? "border-red-300" : "border-gray-300"
          }`}
          {...props}
        >
          {placeholder && (
            <option value="" disabled>
              {placeholder}
            </option>
          )}
          {options.map((opt) => (
            <option key={opt.value} value={opt.value}>
              {opt.label}
            </option>
          ))}
        </select>
      </FormField>
    );
  }
);
Select.displayName = "Select";

Step 6: Checkbox and Radio Components

// components/form/Checkbox.tsx
import { forwardRef, type InputHTMLAttributes } from "react";

interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
  label: string;
  description?: string;
}

export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
  ({ label, description, ...props }, ref) => {
    return (
      <label className="flex items-start gap-3">
        <input
          ref={ref}
          type="checkbox"
          className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
          {...props}
        />
        <div>
          <span className="text-sm font-medium text-gray-900 dark:text-white">
            {label}
          </span>
          {description && (
            <p className="text-xs text-gray-500">{description}</p>
          )}
        </div>
      </label>
    );
  }
);
Checkbox.displayName = "Checkbox";

// components/form/RadioGroup.tsx
interface RadioOption {
  value: string;
  label: string;
  description?: string;
}

interface RadioGroupProps {
  label: string;
  name: string;
  options: RadioOption[];
  value?: string;
  onChange?: (value: string) => void;
  error?: string;
}

export function RadioGroup({
  label,
  name,
  options,
  value,
  onChange,
  error,
}: RadioGroupProps) {
  return (
    <fieldset>
      <legend className="mb-2 text-sm font-medium text-gray-900 dark:text-white">
        {label}
      </legend>
      <div className="space-y-2">
        {options.map((option) => (
          <label
            key={option.value}
            className="flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
          >
            <input
              type="radio"
              name={name}
              value={option.value}
              checked={value === option.value}
              onChange={() => onChange?.(option.value)}
              className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
            />
            <div>
              <span className="text-sm font-medium">{option.label}</span>
              {option.description && (
                <p className="text-xs text-gray-500">{option.description}</p>
              )}
            </div>
          </label>
        ))}
      </div>
      {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
    </fieldset>
  );
}

Step 7: Complete Form Example

"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { TextInput } from "@/components/form/TextInput";
import { TextArea } from "@/components/form/TextArea";
import { Select } from "@/components/form/Select";
import { Checkbox } from "@/components/form/Checkbox";

const schema = z.object({
  name: z.string().min(1, "Name is required"),
  email: z.string().email("Invalid email"),
  company: z.string().optional(),
  service: z.string().min(1, "Please select a service"),
  message: z.string().min(10, "Message must be at least 10 characters"),
  newsletter: z.boolean().optional(),
});

type FormValues = z.infer<typeof schema>;

export function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
  });

  async function onSubmit(data: FormValues) {
    await fetch("/api/contact", {
      method: "POST",
      body: JSON.stringify(data),
    });
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
      <div className="grid grid-cols-2 gap-4">
        <TextInput
          label="Name"
          required
          error={errors.name?.message}
          {...register("name")}
        />
        <TextInput
          label="Email"
          type="email"
          required
          error={errors.email?.message}
          {...register("email")}
        />
      </div>

      <TextInput
        label="Company"
        error={errors.company?.message}
        {...register("company")}
      />

      <Select
        label="Service"
        required
        placeholder="Select a service..."
        options={[
          { value: "web-design", label: "Web Design" },
          { value: "web-development", label: "Web Development" },
          { value: "mobile-app", label: "Mobile App Development" },
          { value: "seo", label: "SEO & Marketing" },
        ]}
        error={errors.service?.message}
        {...register("service")}
      />

      <TextArea
        label="Message"
        required
        error={errors.message?.message}
        {...register("message")}
      />

      <Checkbox
        label="Subscribe to newsletter"
        description="Get weekly tips on web design and development."
        {...register("newsletter")}
      />

      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
      >
        {isSubmitting ? "Sending..." : "Send Message"}
      </button>
    </form>
  );
}

Need Custom Forms Built?

We build web applications with complex forms, validation, and data collection workflows. Contact us to discuss your project.

formsReactcomponentsreusabletutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles