Skip to main content
Back to Blog
Tutorials
5 min read
December 14, 2024

How to Build a Form Builder in React

Create a drag-and-drop form builder in React that lets users assemble custom forms with various field types and validation rules.

Ryel Banfield

Founder & Lead Developer

A form builder lets non-technical users create custom forms without code. Here is how to build one with drag-and-drop.

Field Types

// types/form-builder.ts
export type FieldType =
  | "text"
  | "email"
  | "number"
  | "textarea"
  | "select"
  | "checkbox"
  | "radio"
  | "date"
  | "file";

export interface FieldOption {
  label: string;
  value: string;
}

export interface ValidationRule {
  type: "required" | "minLength" | "maxLength" | "pattern" | "min" | "max";
  value?: string | number;
  message: string;
}

export interface FormField {
  id: string;
  type: FieldType;
  label: string;
  placeholder?: string;
  helpText?: string;
  options?: FieldOption[];
  validation: ValidationRule[];
  defaultValue?: string;
  width?: "full" | "half";
}

export interface FormSchema {
  id: string;
  title: string;
  description?: string;
  fields: FormField[];
  submitLabel?: string;
  successMessage?: string;
}

Form Builder Component

"use client";

import { useState, useCallback } from "react";
import type { FormField, FormSchema, FieldType } from "@/types/form-builder";
import { FieldEditor } from "./FieldEditor";

const FIELD_PALETTE: { type: FieldType; label: string; icon: string }[] = [
  { type: "text", label: "Text Input", icon: "T" },
  { type: "email", label: "Email", icon: "@" },
  { type: "number", label: "Number", icon: "#" },
  { type: "textarea", label: "Text Area", icon: "P" },
  { type: "select", label: "Dropdown", icon: "V" },
  { type: "checkbox", label: "Checkbox", icon: "C" },
  { type: "radio", label: "Radio Group", icon: "O" },
  { type: "date", label: "Date", icon: "D" },
  { type: "file", label: "File Upload", icon: "F" },
];

interface FormBuilderProps {
  initial?: FormSchema;
  onSave: (schema: FormSchema) => void;
}

export function FormBuilder({ initial, onSave }: FormBuilderProps) {
  const [schema, setSchema] = useState<FormSchema>(
    initial ?? {
      id: crypto.randomUUID(),
      title: "Untitled Form",
      fields: [],
      submitLabel: "Submit",
    }
  );
  const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
  const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);

  const addField = useCallback((type: FieldType) => {
    const newField: FormField = {
      id: crypto.randomUUID(),
      type,
      label: `New ${type} field`,
      validation: [],
      width: "full",
      ...(["select", "radio"].includes(type)
        ? { options: [{ label: "Option 1", value: "option-1" }] }
        : {}),
    };

    setSchema((prev) => ({
      ...prev,
      fields: [...prev.fields, newField],
    }));
    setSelectedFieldId(newField.id);
  }, []);

  const updateField = useCallback((id: string, updates: Partial<FormField>) => {
    setSchema((prev) => ({
      ...prev,
      fields: prev.fields.map((f) => (f.id === id ? { ...f, ...updates } : f)),
    }));
  }, []);

  const removeField = useCallback((id: string) => {
    setSchema((prev) => ({
      ...prev,
      fields: prev.fields.filter((f) => f.id !== id),
    }));
    setSelectedFieldId(null);
  }, []);

  const moveField = useCallback((fromIndex: number, toIndex: number) => {
    setSchema((prev) => {
      const fields = [...prev.fields];
      const [moved] = fields.splice(fromIndex, 1);
      fields.splice(toIndex, 0, moved);
      return { ...prev, fields };
    });
  }, []);

  const selectedField = schema.fields.find((f) => f.id === selectedFieldId);

  return (
    <div className="flex h-[calc(100vh-64px)]">
      {/* Left: Field Palette */}
      <div className="w-56 border-r bg-muted/30 p-3 overflow-y-auto">
        <h3 className="text-xs font-semibold uppercase text-muted-foreground mb-3">
          Fields
        </h3>
        <div className="space-y-1">
          {FIELD_PALETTE.map(({ type, label, icon }) => (
            <button
              key={type}
              onClick={() => addField(type)}
              className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded hover:bg-muted text-left"
            >
              <span className="w-6 h-6 bg-muted rounded flex items-center justify-center text-xs font-mono">
                {icon}
              </span>
              {label}
            </button>
          ))}
        </div>
      </div>

      {/* Center: Form Canvas */}
      <div className="flex-1 p-6 overflow-y-auto">
        <div className="max-w-xl mx-auto">
          <input
            value={schema.title}
            onChange={(e) =>
              setSchema((prev) => ({ ...prev, title: e.target.value }))
            }
            className="text-2xl font-bold w-full bg-transparent border-none outline-none mb-1"
            placeholder="Form title"
          />
          <input
            value={schema.description ?? ""}
            onChange={(e) =>
              setSchema((prev) => ({ ...prev, description: e.target.value }))
            }
            className="text-sm text-muted-foreground w-full bg-transparent border-none outline-none mb-6"
            placeholder="Form description (optional)"
          />

          {schema.fields.length === 0 ? (
            <div className="border-2 border-dashed rounded-lg p-12 text-center text-muted-foreground">
              <p className="text-sm">
                Click a field type on the left to add it to your form
              </p>
            </div>
          ) : (
            <div className="space-y-3">
              {schema.fields.map((field, index) => (
                <div
                  key={field.id}
                  draggable
                  onDragStart={(e) => e.dataTransfer.setData("fieldIndex", String(index))}
                  onDragOver={(e) => { e.preventDefault(); setDragOverIndex(index); }}
                  onDragLeave={() => setDragOverIndex(null)}
                  onDrop={(e) => {
                    e.preventDefault();
                    const from = Number(e.dataTransfer.getData("fieldIndex"));
                    moveField(from, index);
                    setDragOverIndex(null);
                  }}
                  onClick={() => setSelectedFieldId(field.id)}
                  className={`
                    border rounded-lg p-3 cursor-pointer transition-all
                    ${selectedFieldId === field.id ? "ring-2 ring-primary border-primary" : "hover:border-foreground/20"}
                    ${dragOverIndex === index ? "border-primary border-dashed" : ""}
                  `}
                >
                  <FieldPreview field={field} />
                </div>
              ))}
            </div>
          )}

          <div className="mt-6 flex gap-2">
            <button
              onClick={() => onSave(schema)}
              className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm"
            >
              Save Form
            </button>
          </div>
        </div>
      </div>

      {/* Right: Field Settings */}
      {selectedField && (
        <div className="w-72 border-l bg-muted/30 p-4 overflow-y-auto">
          <FieldEditor
            field={selectedField}
            onUpdate={(updates) => updateField(selectedField.id, updates)}
            onRemove={() => removeField(selectedField.id)}
          />
        </div>
      )}
    </div>
  );
}

Field Preview

function FieldPreview({ field }: { field: FormField }) {
  const isRequired = field.validation.some((v) => v.type === "required");

  return (
    <div>
      <label className="text-sm font-medium">
        {field.label}
        {isRequired && <span className="text-red-500 ml-0.5">*</span>}
      </label>
      {field.helpText && (
        <p className="text-xs text-muted-foreground mt-0.5">{field.helpText}</p>
      )}
      <div className="mt-1.5 pointer-events-none">
        {field.type === "textarea" ? (
          <div className="h-16 border rounded bg-background px-3 py-2 text-sm text-muted-foreground">
            {field.placeholder ?? ""}
          </div>
        ) : field.type === "select" ? (
          <div className="border rounded bg-background px-3 py-2 text-sm text-muted-foreground">
            {field.options?.[0]?.label ?? "Select..."}
          </div>
        ) : field.type === "checkbox" ? (
          <div className="flex items-center gap-2">
            <div className="w-4 h-4 border rounded" />
            <span className="text-sm">{field.options?.[0]?.label ?? "Checkbox"}</span>
          </div>
        ) : field.type === "radio" ? (
          <div className="space-y-1">
            {(field.options ?? []).map((opt) => (
              <div key={opt.value} className="flex items-center gap-2">
                <div className="w-4 h-4 border rounded-full" />
                <span className="text-sm">{opt.label}</span>
              </div>
            ))}
          </div>
        ) : (
          <div className="border rounded bg-background px-3 py-2 text-sm text-muted-foreground">
            {field.placeholder ?? ""}
          </div>
        )}
      </div>
    </div>
  );
}

Form Renderer

"use client";

import { useForm } from "react-hook-form";
import type { FormSchema } from "@/types/form-builder";

export function FormRenderer({
  schema,
  onSubmit,
}: {
  schema: FormSchema;
  onSubmit: (data: Record<string, unknown>) => void;
}) {
  const form = useForm();

  return (
    <form
      onSubmit={form.handleSubmit(onSubmit)}
      className="max-w-xl mx-auto space-y-4"
    >
      <h1 className="text-2xl font-bold">{schema.title}</h1>
      {schema.description && (
        <p className="text-muted-foreground">{schema.description}</p>
      )}

      {schema.fields.map((field) => {
        const isRequired = field.validation.some((v) => v.type === "required");

        return (
          <div key={field.id}>
            <label htmlFor={field.id} className="text-sm font-medium block mb-1">
              {field.label}
              {isRequired && <span className="text-red-500 ml-0.5">*</span>}
            </label>
            {renderField(field, form.register)}
            {field.helpText && (
              <p className="text-xs text-muted-foreground mt-1">{field.helpText}</p>
            )}
          </div>
        );
      })}

      <button
        type="submit"
        className="bg-primary text-primary-foreground px-4 py-2 rounded"
      >
        {schema.submitLabel ?? "Submit"}
      </button>
    </form>
  );
}

function renderField(field: FormField, register: any) {
  const validation: Record<string, unknown> = {};
  for (const rule of field.validation) {
    if (rule.type === "required") validation.required = rule.message;
    if (rule.type === "minLength") validation.minLength = { value: rule.value, message: rule.message };
    if (rule.type === "maxLength") validation.maxLength = { value: rule.value, message: rule.message };
    if (rule.type === "pattern") validation.pattern = { value: new RegExp(rule.value as string), message: rule.message };
  }

  switch (field.type) {
    case "textarea":
      return (
        <textarea
          id={field.id}
          placeholder={field.placeholder}
          className="w-full border rounded px-3 py-2 text-sm"
          rows={4}
          {...register(field.id, validation)}
        />
      );
    case "select":
      return (
        <select id={field.id} className="w-full border rounded px-3 py-2 text-sm" {...register(field.id, validation)}>
          <option value="">Select...</option>
          {field.options?.map((opt) => (
            <option key={opt.value} value={opt.value}>{opt.label}</option>
          ))}
        </select>
      );
    case "checkbox":
      return (
        <div className="flex items-center gap-2">
          <input type="checkbox" id={field.id} {...register(field.id, validation)} />
        </div>
      );
    case "radio":
      return (
        <div className="space-y-1">
          {field.options?.map((opt) => (
            <label key={opt.value} className="flex items-center gap-2 text-sm">
              <input type="radio" value={opt.value} {...register(field.id, validation)} />
              {opt.label}
            </label>
          ))}
        </div>
      );
    default:
      return (
        <input
          id={field.id}
          type={field.type}
          placeholder={field.placeholder}
          className="w-full border rounded px-3 py-2 text-sm"
          {...register(field.id, validation)}
        />
      );
  }
}

Need Custom Forms for Your Business?

We build dynamic form systems and data collection tools. Contact us to learn more.

form builderdynamic formsReactdrag-and-droptutorial

Ready to Start Your Project?

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

Get in Touch

Related Articles