Blog/Tutorials & Step-by-Step/Build a Drag-and-Drop Form Builder with React
POST
November 22, 2025
LAST UPDATEDNovember 22, 2025

Build a Drag-and-Drop Form Builder with React

Learn how to build a drag-and-drop form builder in React using dnd-kit, with field configuration panels, form preview, JSON schema export, and conditional logic support.

Tags

ReactDrag and DropForm BuilderUI
Build a Drag-and-Drop Form Builder with React
4 min read

Build a Drag-and-Drop Form Builder with React

TL;DR

Using dnd-kit with a field registry pattern, you can build a fully configurable drag-and-drop form builder that exports clean JSON schemas and handles conditional logic without third-party form platforms.

Prerequisites

  • React 18+ with TypeScript
  • Familiarity with React state management (useState, useReducer)
  • Node.js 18+ installed
  • Basic understanding of JSON schema concepts

Step 1: Project Setup and Installing Dependencies

Start by creating a new React project and installing the required dnd-kit packages.

bash
npx create-react-app form-builder --template typescript
cd form-builder
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
npm install uuid
npm install -D @types/uuid

dnd-kit is split into modular packages. @dnd-kit/core provides the drag-and-drop engine, @dnd-kit/sortable adds sortable list behavior, and @dnd-kit/utilities includes helpful CSS transform utilities.

Step 2: Define the Field Schema Types

Before building any UI, define the types that represent your form schema. This is the data structure your builder will produce.

typescript
// types/form.ts
export type FieldType = "text" | "textarea" | "select" | "checkbox" | "number" | "email";
 
export interface FieldOption {
  label: string;
  value: string;
}
 
export interface FormField {
  id: string;
  type: FieldType;
  label: string;
  placeholder?: string;
  required: boolean;
  options?: FieldOption[]; // For select fields
  defaultValue?: string;
  validation?: {
    minLength?: number;
    maxLength?: number;
    pattern?: string;
  };
}
 
export interface FormSchema {
  title: string;
  description?: string;
  fields: FormField[];
}

Why a Field Registry?

Instead of hardcoding field types throughout the app, create a registry that maps each field type to its configuration defaults and its render component.

typescript
// registry/fieldRegistry.ts
import { FieldType, FormField } from "../types/form";
import { v4 as uuidv4 } from "uuid";
 
export const fieldDefaults: Record<FieldType, Omit<FormField, "id">> = {
  text: { type: "text", label: "Text Field", placeholder: "Enter text...", required: false },
  textarea: { type: "textarea", label: "Text Area", placeholder: "Enter long text...", required: false },
  select: {
    type: "select",
    label: "Dropdown",
    required: false,
    options: [{ label: "Option 1", value: "option1" }],
  },
  checkbox: { type: "checkbox", label: "Checkbox", required: false, defaultValue: "false" },
  number: { type: "number", label: "Number Field", placeholder: "0", required: false },
  email: { type: "email", label: "Email Field", placeholder: "you@example.com", required: false },
};
 
export function createField(type: FieldType): FormField {
  return { id: uuidv4(), ...fieldDefaults[type] };
}

Step 3: Build the Field Palette (Drag Source)

The palette is the sidebar where users pick fields to drag onto the canvas. Each palette item acts as a drag source.

tsx
// components/FieldPalette.tsx
import { useDraggable } from "@dnd-kit/core";
import { FieldType } from "../types/form";
 
const fieldTypes: { type: FieldType; label: string; icon: string }[] = [
  { type: "text", label: "Text Input", icon: "T" },
  { type: "textarea", label: "Text Area", icon: "P" },
  { type: "select", label: "Dropdown", icon: "V" },
  { type: "checkbox", label: "Checkbox", icon: "C" },
  { type: "number", label: "Number", icon: "#" },
  { type: "email", label: "Email", icon: "@" },
];
 
function DraggablePaletteItem({ type, label, icon }: { type: FieldType; label: string; icon: string }) {
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
    id: `palette-${type}`,
    data: { type, fromPalette: true },
  });
 
  return (
    <div
      ref={setNodeRef}
      {...listeners}
      {...attributes}
      className={`palette-item ${isDragging ? "dragging" : ""}`}
    >
      <span className="palette-icon">{icon}</span>
      <span>{label}</span>
    </div>
  );
}
 
export function FieldPalette() {
  return (
    <div className="field-palette">
      <h3>Form Fields</h3>
      {fieldTypes.map((field) => (
        <DraggablePaletteItem key={field.type} {...field} />
      ))}
    </div>
  );
}

Step 4: Build the Sortable Form Canvas

The canvas is where dropped fields live and can be reordered. This uses @dnd-kit/sortable for reordering.

tsx
// components/FormCanvas.tsx
import {
  SortableContext,
  verticalListSortingStrategy,
  useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { FormField } from "../types/form";
 
interface SortableFieldProps {
  field: FormField;
  isSelected: boolean;
  onSelect: (id: string) => void;
  onRemove: (id: string) => void;
}
 
function SortableField({ field, isSelected, onSelect, onRemove }: SortableFieldProps) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id: field.id,
  });
 
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };
 
  return (
    <div
      ref={setNodeRef}
      style={style}
      className={`canvas-field ${isSelected ? "selected" : ""}`}
      onClick={() => onSelect(field.id)}
    >
      <div className="field-drag-handle" {...attributes} {...listeners}>
        &#x2630;
      </div>
      <div className="field-preview">
        <label>{field.label}{field.required && " *"}</label>
        {field.type === "text" && <input type="text" placeholder={field.placeholder} disabled />}
        {field.type === "textarea" && <textarea placeholder={field.placeholder} disabled />}
        {field.type === "select" && (
          <select disabled>
            {field.options?.map((opt) => (
              <option key={opt.value}>{opt.label}</option>
            ))}
          </select>
        )}
        {field.type === "checkbox" && <input type="checkbox" disabled />}
        {field.type === "number" && <input type="number" placeholder={field.placeholder} disabled />}
        {field.type === "email" && <input type="email" placeholder={field.placeholder} disabled />}
      </div>
      <button className="remove-btn" onClick={() => onRemove(field.id)}>X</button>
    </div>
  );
}
 
interface FormCanvasProps {
  fields: FormField[];
  selectedFieldId: string | null;
  onSelectField: (id: string) => void;
  onRemoveField: (id: string) => void;
}
 
export function FormCanvas({ fields, selectedFieldId, onSelectField, onRemoveField }: FormCanvasProps) {
  return (
    <div className="form-canvas">
      <SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
        {fields.length === 0 && (
          <div className="canvas-empty">Drag fields here to build your form</div>
        )}
        {fields.map((field) => (
          <SortableField
            key={field.id}
            field={field}
            isSelected={selectedFieldId === field.id}
            onSelect={onSelectField}
            onRemove={onRemoveField}
          />
        ))}
      </SortableContext>
    </div>
  );
}

Step 5: Build the Field Configuration Panel

When a user clicks a field on the canvas, a side panel shows its configurable properties.

tsx
// components/FieldConfigPanel.tsx
import { FormField, FieldOption } from "../types/form";
 
interface FieldConfigPanelProps {
  field: FormField;
  onUpdate: (id: string, updates: Partial<FormField>) => void;
}
 
export function FieldConfigPanel({ field, onUpdate }: FieldConfigPanelProps) {
  const handleChange = (key: keyof FormField, value: unknown) => {
    onUpdate(field.id, { [key]: value });
  };
 
  const handleOptionChange = (index: number, key: keyof FieldOption, value: string) => {
    const updatedOptions = [...(field.options || [])];
    updatedOptions[index] = { ...updatedOptions[index], [key]: value };
    onUpdate(field.id, { options: updatedOptions });
  };
 
  const addOption = () => {
    const options = [...(field.options || []), { label: "New Option", value: `option${Date.now()}` }];
    onUpdate(field.id, { options });
  };
 
  const removeOption = (index: number) => {
    const options = (field.options || []).filter((_, i) => i !== index);
    onUpdate(field.id, { options });
  };
 
  return (
    <div className="config-panel">
      <h3>Field Settings</h3>
 
      <div className="config-group">
        <label>Label</label>
        <input
          type="text"
          value={field.label}
          onChange={(e) => handleChange("label", e.target.value)}
        />
      </div>
 
      <div className="config-group">
        <label>Placeholder</label>
        <input
          type="text"
          value={field.placeholder || ""}
          onChange={(e) => handleChange("placeholder", e.target.value)}
        />
      </div>
 
      <div className="config-group">
        <label>
          <input
            type="checkbox"
            checked={field.required}
            onChange={(e) => handleChange("required", e.target.checked)}
          />
          Required
        </label>
      </div>
 
      {field.type === "select" && (
        <div className="config-group">
          <label>Options</label>
          {field.options?.map((opt, i) => (
            <div key={i} className="option-row">
              <input
                value={opt.label}
                onChange={(e) => handleOptionChange(i, "label", e.target.value)}
                placeholder="Label"
              />
              <input
                value={opt.value}
                onChange={(e) => handleOptionChange(i, "value", e.target.value)}
                placeholder="Value"
              />
              <button onClick={() => removeOption(i)}>-</button>
            </div>
          ))}
          <button onClick={addOption}>Add Option</button>
        </div>
      )}
 
      {(field.type === "text" || field.type === "textarea") && (
        <div className="config-group">
          <label>Max Length</label>
          <input
            type="number"
            value={field.validation?.maxLength || ""}
            onChange={(e) =>
              handleChange("validation", {
                ...field.validation,
                maxLength: parseInt(e.target.value) || undefined,
              })
            }
          />
        </div>
      )}
    </div>
  );
}

Step 6: Wire It All Together with DndContext

The main component sets up the DndContext and manages the state for fields, selection, and drag events.

tsx
// App.tsx
import { useState, useCallback } from "react";
import {
  DndContext,
  DragEndEvent,
  DragOverlay,
  PointerSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import { FormField, FieldType, FormSchema } from "./types/form";
import { createField } from "./registry/fieldRegistry";
import { FieldPalette } from "./components/FieldPalette";
import { FormCanvas } from "./components/FormCanvas";
import { FieldConfigPanel } from "./components/FieldConfigPanel";
 
export default function App() {
  const [fields, setFields] = useState<FormField[]>([]);
  const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
 
  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
  );
 
  const handleDragEnd = useCallback((event: DragEndEvent) => {
    const { active, over } = event;
    if (!over) return;
 
    // Dropping from palette to canvas
    if (active.data.current?.fromPalette) {
      const fieldType = active.data.current.type as FieldType;
      const newField = createField(fieldType);
      setFields((prev) => [...prev, newField]);
      setSelectedFieldId(newField.id);
      return;
    }
 
    // Reordering within canvas
    if (active.id !== over.id) {
      setFields((prev) => {
        const oldIndex = prev.findIndex((f) => f.id === active.id);
        const newIndex = prev.findIndex((f) => f.id === over.id);
        return arrayMove(prev, oldIndex, newIndex);
      });
    }
  }, []);
 
  const handleRemoveField = (id: string) => {
    setFields((prev) => prev.filter((f) => f.id !== id));
    if (selectedFieldId === id) setSelectedFieldId(null);
  };
 
  const handleUpdateField = (id: string, updates: Partial<FormField>) => {
    setFields((prev) =>
      prev.map((f) => (f.id === id ? { ...f, ...updates } : f))
    );
  };
 
  const selectedField = fields.find((f) => f.id === selectedFieldId);
 
  const exportSchema = (): FormSchema => ({
    title: "My Form",
    fields: fields.map(({ id, ...rest }) => ({ id, ...rest })),
  });
 
  return (
    <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
      <div className="builder-layout">
        <FieldPalette />
        <FormCanvas
          fields={fields}
          selectedFieldId={selectedFieldId}
          onSelectField={setSelectedFieldId}
          onRemoveField={handleRemoveField}
        />
        {selectedField && (
          <FieldConfigPanel field={selectedField} onUpdate={handleUpdateField} />
        )}
      </div>
      <button onClick={() => console.log(JSON.stringify(exportSchema(), null, 2))}>
        Export JSON Schema
      </button>
    </DndContext>
  );
}

Understanding the Drag Sensors

The PointerSensor with a distance constraint of 5 pixels prevents accidental drags when clicking. This is important because the canvas fields are both clickable (to select) and draggable (to reorder). Without the distance constraint, every click would trigger a drag event.

Step 7: Export and Render the JSON Schema

The schema export produces a clean JSON object that can be stored in a database and rendered dynamically by a separate form renderer.

tsx
// components/FormRenderer.tsx
import { FormSchema, FormField } from "../types/form";
import { useState } from "react";
 
function RenderField({ field }: { field: FormField }) {
  const [value, setValue] = useState(field.defaultValue || "");
 
  switch (field.type) {
    case "text":
    case "email":
    case "number":
      return (
        <div className="form-group">
          <label>{field.label}{field.required && " *"}</label>
          <input
            type={field.type}
            placeholder={field.placeholder}
            value={value}
            onChange={(e) => setValue(e.target.value)}
            required={field.required}
            maxLength={field.validation?.maxLength}
          />
        </div>
      );
    case "textarea":
      return (
        <div className="form-group">
          <label>{field.label}{field.required && " *"}</label>
          <textarea
            placeholder={field.placeholder}
            value={value}
            onChange={(e) => setValue(e.target.value)}
            required={field.required}
          />
        </div>
      );
    case "select":
      return (
        <div className="form-group">
          <label>{field.label}{field.required && " *"}</label>
          <select value={value} onChange={(e) => setValue(e.target.value)} required={field.required}>
            <option value="">Select...</option>
            {field.options?.map((opt) => (
              <option key={opt.value} value={opt.value}>{opt.label}</option>
            ))}
          </select>
        </div>
      );
    case "checkbox":
      return (
        <div className="form-group">
          <label>
            <input
              type="checkbox"
              checked={value === "true"}
              onChange={(e) => setValue(String(e.target.checked))}
            />
            {field.label}
          </label>
        </div>
      );
    default:
      return null;
  }
}
 
export function FormRenderer({ schema }: { schema: FormSchema }) {
  return (
    <form onSubmit={(e) => { e.preventDefault(); alert("Submitted!"); }}>
      <h2>{schema.title}</h2>
      {schema.fields.map((field) => (
        <RenderField key={field.id} field={field} />
      ))}
      <button type="submit">Submit</button>
    </form>
  );
}

Putting It All Together

Your form builder now has three panels working in concert:

  1. Field Palette -- drag source for new fields
  2. Form Canvas -- sortable drop zone where fields live and can be reordered
  3. Config Panel -- edit properties of the selected field

The entire form state lives in a single fields array. Dragging from the palette appends a new field. Dragging within the canvas reorders using arrayMove. Clicking a field reveals its config panel. And the export button serializes the current state into a portable JSON schema.

To persist forms, save the exported JSON to your database. To render submitted forms, pass the JSON schema to FormRenderer and it dynamically builds the form from the schema.

Next Steps

  • Add validation rules -- extend the config panel to support min/max values, regex patterns, and custom error messages
  • Conditional logic -- add a conditions array to each field that references other field values to show/hide fields dynamically
  • Multi-column layouts -- use CSS Grid and allow fields to be dragged into row/column containers
  • Undo/redo -- track field state history with a stack to support ctrl+z workflows
  • Persist to a database -- save schemas to PostgreSQL or MongoDB and build a form listing page

FAQ

What library is best for drag-and-drop in React?

dnd-kit is the modern standard for drag-and-drop in React. It offers excellent accessibility, flexible sensor APIs, and performant sortable lists out of the box, making it ideal for form builder interfaces.

How do you store form builder configurations?

Form configurations are typically stored as JSON schemas that describe field types, validation rules, and layout order. This schema can be persisted in a database and rendered dynamically at runtime using a form renderer component.

Can a React form builder support conditional logic?

Yes. By adding a conditions array to each field schema entry, you can define show/hide rules based on other field values. The form renderer evaluates these conditions on each state change to toggle field visibility dynamically.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Add Observability to a Node.js App with OpenTelemetry
Mar 21, 20265 min read
Node.js
OpenTelemetry
Observability

How to Add Observability to a Node.js App with OpenTelemetry

Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
Mar 21, 20266 min read
Next.js
Node.js
BFF

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js

A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.

How I Structure CI/CD for Next.js, Docker, and GitHub Actions
Mar 21, 20265 min read
CI/CD
Next.js
Docker

How I Structure CI/CD for Next.js, Docker, and GitHub Actions

A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.