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
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.
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/uuiddnd-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.
// 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.
// 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.
// 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.
// 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}>
☰
</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.
// 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.
// 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.
// 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:
- ›Field Palette -- drag source for new fields
- ›Form Canvas -- sortable drop zone where fields live and can be reordered
- ›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
conditionsarray 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.
Related Articles
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
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
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.