Frontend System Design
20 min read

Design a Form Builder

Schema-driven form generation systems that render dynamic UIs from declarative definitions. This article covers form schema architectures, validation strategies, state management patterns, and the trade-offs that shape production form builders like Typeform, Google Forms, and enterprise low-code platforms.

Persistence

Form Runtime

Form Builder

JSON Schema

Field definitions

UI Schema

Rendering hints

Logic Rules

Conditions

Component

Registry

Schema

Renderer

Form State

Manager

Validation

Engine

Draft Storage

IndexedDB

Submission

Backend

Form builder architecture: schemas define structure, runtime renders and validates, persistence handles drafts and submissions.

A form builder separates what (schema) from how (rendering) through three layers:

  1. Schema Layer — JSON Schema or custom DSL defines field types, validation rules, and relationships. TypeScript inference provides compile-time safety.

  2. Runtime Layer — Component registry maps field types to UI components. State manager tracks values, errors, dirty state. Validation engine supports sync, async, and cross-field rules.

  3. Persistence Layer — Autosave to IndexedDB for draft recovery. Debounced saves prevent data loss without server overhead.

Key architectural decisions:

DecisionOptionsTrade-off
State approachControlled vs UncontrolledRe-renders vs Direct DOM access
Validation timingonChange vs onBlur vs onSubmitResponsiveness vs Performance
Schema formatJSON Schema vs Custom DSLInteroperability vs Flexibility
Field renderingEager vs VirtualizedSimplicity vs Scale

Forms appear simple but encode complex state machines: field visibility depends on other fields, validation rules cross boundaries, and user expectations around autosave and error recovery are high.

ConstraintImpactMitigation
Main thread (16ms budget)Validation blocking inputDebounce, Web Workers
Memory (mobile: 50-100MB practical)Large form state treesVirtualization, state pruning
localStorage (5MB)Draft storage limitsIndexedDB (50% of disk)
Re-render costSluggish typing feedbackUncontrolled inputs, subscriptions
FactorSmallMediumLarge
Fields< 5050-200> 200
Nested depth1-2 levels3-4 levels5+ levels
Conditional rules< 1010-50> 50
Update frequencySubmit onlyBlurEvery keystroke

Large-scale forms (tax software, insurance applications, enterprise workflows) require virtualization and careful state management to remain responsive.

Architecture:

JSON Schema

AJV Validator

RJSF Renderer

UI Schema

Rendered Form

Form Data

How it works:

JSON Schema defines data structure and validation constraints. A separate UI Schema specifies rendering hints (widgets, field order, layout). The renderer traverses both schemas to generate the form.

schema-example.ts
2 collapsed lines
// JSON Schema defines data shape and validation
import type { JSONSchema7 } from "json-schema"
const schema: JSONSchema7 = {
type: "object",
required: ["email", "role"],
properties: {
email: {
type: "string",
format: "email",
title: "Email Address",
},
role: {
type: "string",
enum: ["admin", "user", "guest"],
title: "Role",
},
permissions: {
type: "array",
items: { type: "string" },
title: "Permissions",
},
},
}
// UI Schema controls rendering
const uiSchema = {
email: { "ui:autofocus": true },
role: { "ui:widget": "radio" },
permissions: { "ui:widget": "checkboxes" },
}

Best for:

  • Standards-compliant form definitions shared across systems
  • Backend-generated forms (server defines schema, client renders)
  • API documentation integration (OpenAPI to form generation)

Device/network profile:

  • Works well on: Desktop, stable networks (schema fetching)
  • Struggles on: Low-end mobile with complex nested schemas (parsing overhead)

Implementation complexity:

AspectEffort
Initial setupLow (libraries handle rendering)
Custom widgetsMedium (widget registry)
Complex conditionalsHigh (JSON Schema if/then/else)
Type safetyMedium (inference libraries exist)

Real-world example:

React JSON Schema Form (RJSF) powers forms at Mozilla, Postman, and numerous enterprise tools. The schema-first approach enables form definitions stored in databases and shared between frontend and backend validation.

Trade-offs:

  • Schema reusable across platforms (web, mobile, validation)
  • AJV validation is battle-tested and fast
  • UI Schema adds second layer of complexity
  • Complex conditional logic is verbose in JSON Schema

Architecture:

TypeScript Schema

(Zod/TypeBox)

Inferred Types

Runtime Validator

Type-safe Form

How it works:

Define schemas in TypeScript with libraries like Zod or TypeBox. Types are inferred automatically—write once, get both compile-time checking and runtime validation.

zod-schema.ts
3 collapsed lines
// TypeScript-first schema with Zod
import { z } from "zod"
const userSchema = z
.object({
email: z.string().email("Invalid email format"),
age: z.number().min(18, "Must be 18 or older"),
role: z.enum(["admin", "user", "guest"]),
// Cross-field validation
})
.refine((data) => data.role !== "admin" || data.age >= 21, { message: "Admins must be 21+", path: ["age"] })
// Type is automatically inferred
type User = z.infer<typeof userSchema>
// { email: string; age: number; role: "admin" | "user" | "guest" }
// Async validation example
const uniqueEmailSchema = z
.string()
.email()
.refine(
async (email) => {
const exists = await checkEmailExists(email)
return !exists
},
{ message: "Email already registered" },
)

Best for:

  • TypeScript-heavy codebases wanting type inference
  • Complex validation logic (easier in code than JSON)
  • React Hook Form or similar library integration

Device/network profile:

  • Works well on: All devices (validation runs locally)
  • Struggles on: Async validation on slow networks (needs debouncing)

Implementation complexity:

AspectEffort
Initial setupLow (npm install + schema)
Type inferenceAutomatic
Custom validationLow (just write functions)
Schema serializationMedium (not JSON-native)

Real-world example:

tRPC, Remix, and Next.js applications commonly use Zod for end-to-end type safety. The schema validates both client forms and server API handlers.

Trade-offs:

  • Full TypeScript inference without code generation
  • Complex validation logic is natural in code
  • Excellent React Hook Form integration via resolvers
  • Not portable to non-JS backends (unlike JSON Schema)
  • Schema not easily stored in database

Architecture:

START

NEXT

INVALID

VALID

NEXT

BACK

INVALID

VALID

DONE

FAIL

RETRY

idle

step1

validating1

step2

validating2

submitting

success

error

How it works:

Model the form as a finite state machine. Each step is a state, transitions are guarded by validation, and context holds accumulated form data. XState handles complex flows like multi-step wizards, conditional branching, and async operations.

form-machine.ts
5 collapsed lines
// XState form wizard machine
import { createMachine, assign } from "xstate"
type FormContext = {
step1Data: { name: string } | null
step2Data: { email: string } | null
errors: Record<string, string>
}
const formMachine = createMachine({
id: "formWizard",
initial: "step1",
context: {
step1Data: null,
step2Data: null,
errors: {},
} as FormContext,
states: {
step1: {
on: {
NEXT: {
target: "step2",
guard: "isStep1Valid",
actions: assign({
step1Data: (_, event) => event.data,
}),
},
},
},
step2: {
on: {
NEXT: { target: "submitting", guard: "isStep2Valid" },
BACK: "step1",
},
},
submitting: {
invoke: {
src: "submitForm",
onDone: "success",
onError: { target: "error", actions: "setError" },
},
},
success: { type: "final" },
error: { on: { RETRY: "submitting" } },
2 collapsed lines
},
})

Best for:

  • Multi-step wizards with complex branching
  • Forms with strict workflow requirements (can’t skip steps)
  • Audit trails (state machine history is explicit)

Device/network profile:

  • Works well on: All devices (state logic is lightweight)
  • Struggles on: None specific (XState is efficient)

Implementation complexity:

AspectEffort
Initial setupMedium (XState learning curve)
Simple formsOverkill
Complex flowsLow (states make flows explicit)
DebuggingLow (XState Viz, state inspection)

Real-world example:

Insurance quote flows, loan applications, and checkout processes use state machines. The explicit states prevent impossible transitions (e.g., submitting without completing required steps).

Trade-offs:

  • Complex flows are explicit and visualizable
  • Guards prevent invalid transitions
  • Built-in async handling for submissions
  • Overhead for simple single-page forms
  • Two systems to maintain (state machine + form library)
FactorJSON SchemaZod/TypeBoxXState
Type safetyMedium (with inference libs)High (native)Medium
PortabilityHigh (cross-platform)Low (JS only)Low
Complex validationVerboseNaturalVia guards
Multi-step flowsManualManualBuilt-in
Learning curveLowLowMedium
Best forAPI-driven formsTS appsWizard flows

The fundamental form state decision affects every re-render:

Controlled (Formik, Redux Form):

controlled.tsx
// Every keystroke updates React state → re-render
const [value, setValue] = useState("");
<input value={value} onChange={(e) => setValue(e.target.value)} />;
  • React controls the source of truth
  • Easy to derive computed values
  • Re-renders on every keystroke (performance cost)

Uncontrolled (React Hook Form):

uncontrolled.tsx
2 collapsed lines
// DOM is source of truth, React reads on demand
import { useForm } from "react-hook-form";
const { register, handleSubmit } = useForm();
<input {...register("email")} />;
// Only re-renders when explicitly subscribed
  • Minimal re-renders (DOM handles input)
  • Better performance for large forms
  • Less control over intermediate states
  • Need Controller for custom components

Performance comparison (100 fields, keystroke in one field):

ApproachRe-rendersTime
Controlled (naive)100 components~50ms
Controlled (optimized)1 component~5ms
Uncontrolled0 components~1ms

React Final Form and React Hook Form use subscriptions to limit re-renders:

subscriptions.tsx
3 collapsed lines
// Only subscribe to what you need
import { useFormState, useWatch } from "react-hook-form"
// Component only re-renders when these specific values change
const { isDirty, isValid } = useFormState({
control,
subscription: { isDirty: true, isValid: true },
})
// Watch specific field, not entire form
const email = useWatch({ control, name: "email" })

Design rationale: Subscriptions let components opt-in to state changes, similar to GraphQL selecting only needed fields. This prevents the cascading re-renders that plague naive form implementations.

ApproachState LocationRe-render ScopeUse Case
Form-levelSingle storeEntire formSimple forms
Field-levelPer-fieldSingle fieldLarge forms
HybridForm + subscriptionsSubscribed onlyProduction apps

Recommendation: Start with form-level state (simpler). Move to subscriptions when profiling shows re-render bottlenecks (typically > 50 fields or complex computed values).

TimingUXPerformanceUse Case
onChangeImmediate feedbackHigh cost (every keystroke)Critical fields
onBlurFeedback after interactionLow costDefault choice
onSubmitBatch validationLowest costSimple forms
Debounced onChangeBalancedMedium costAsync validation

Debounced async validation pattern:

debounced-validation.ts
5 collapsed lines
// Debounce async validation to avoid API spam
import { z } from "zod"
import debounce from "lodash.debounce"
const checkUsername = debounce(async (username: string) => {
const response = await fetch(`/api/check-username?q=${username}`)
return response.json()
}, 300)
const usernameSchema = z
.string()
.min(3, "Username too short")
.refine(
async (username) => {
const { available } = await checkUsername(username)
return available
},
{ message: "Username taken" },
)

Fields that depend on each other require schema-level validation:

cross-field.ts
2 collapsed lines
// Password confirmation with superRefine for control
import { z } from "zod"
const passwordSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords don't match",
path: ["confirmPassword"], // Error shows on confirm field
})
}
})
// Conditional required field
const eventSchema = z
.object({
eventType: z.enum(["online", "in-person"]),
venue: z.string().optional(),
meetingUrl: z.string().url().optional(),
})
.superRefine((data, ctx) => {
if (data.eventType === "in-person" && !data.venue) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Venue required for in-person events",
path: ["venue"],
})
}
if (data.eventType === "online" && !data.meetingUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Meeting URL required for online events",
path: ["meetingUrl"],
})
}
})

Why superRefine over refine: The refine method only creates a single error with custom error code. superRefine gives full control: multiple errors, specific error codes, precise path targeting.

Zod and similar libraries execute validation in order:

  1. Type coercion/parsing
  2. Built-in validators (min, max, email, etc.)
  3. Refinements (in declaration order)

Critical limitation: Refinements on an object only run if all fields pass their individual validations first. A missing required field prevents cross-field refinements from executing.

execution-order.ts
// Refinement won't run if email is invalid
z.object({
email: z.string().email(),
confirmEmail: z.string(),
}).refine((data) => data.email === data.confirmEmail, { message: "Emails must match" })
// If email fails .email() check, refine never executes

Map field types to UI components for schema-driven rendering:

registry.ts
8 collapsed lines
// Component registry for dynamic field rendering
import type { ComponentType } from "react";
interface FieldProps {
name: string;
label: string;
error?: string;
// ... other common props
}
type FieldRegistry = Record<string, ComponentType<FieldProps>>;
const fieldRegistry: FieldRegistry = {
text: TextInput,
email: EmailInput,
select: SelectField,
checkbox: CheckboxField,
date: DatePicker,
file: FileUpload,
// Custom fields
address: AddressAutocomplete,
phone: PhoneInput
};
// Renderer uses registry to instantiate components
function renderField(field: SchemaField) {
const Component = fieldRegistry[field.type];
if (!Component) {
console.warn(`Unknown field type: ${field.type}`);
return null;
}
return <Component key={field.name} {...field} />;
}

Why a registry: Decouples schema from implementation. The schema says “render a date field”—the registry decides whether that’s a native <input type="date">, a third-party date picker, or a custom component.

Fields that show/hide based on other values:

conditional-fields.tsx
10 collapsed lines
// Conditional rendering based on field values
import { useWatch } from "react-hook-form";
interface ConditionalFieldProps {
watchField: string;
condition: (value: unknown) => boolean;
children: React.ReactNode;
}
function ConditionalField({
watchField,
condition,
children
}: ConditionalFieldProps) {
const value = useWatch({ name: watchField });
if (!condition(value)) {
return null;
}
return <>{children}</>;
}
// Usage in form
<SelectField name="employmentType" options={["employed", "self-employed", "unemployed"]} />
<ConditionalField
watchField="employmentType"
condition={(v) => v === "employed"}
>
<TextInput name="employerName" label="Employer Name" />
<TextInput name="jobTitle" label="Job Title" />
</ConditionalField>
<ConditionalField
watchField="employmentType"
condition={(v) => v === "self-employed"}
>
<TextInput name="businessName" label="Business Name" />
</ConditionalField>

Dropdowns that depend on previous selections:

cascading.tsx
15 collapsed lines
// Cascading dropdowns with dependent options
import { useWatch, useFormContext } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
function CitySelect() {
const { setValue } = useFormContext();
const country = useWatch({ name: "country" });
const state = useWatch({ name: "state" });
// Reset downstream fields when parent changes
useEffect(() => {
setValue("city", "");
}, [state, setValue]);
const { data: cities, isLoading } = useQuery({
queryKey: ["cities", country, state],
queryFn: () => fetchCities(country, state),
enabled: Boolean(country && state)
});
if (!state) return null;
return (
<SelectField
name="city"
options={cities ?? []}
disabled={isLoading}
placeholder={isLoading ? "Loading cities..." : "Select city"}
/>
);
}

Design consideration: Reset dependent fields when parent changes. Stale values (city that doesn’t exist in newly selected state) cause validation errors and data integrity issues.

Repeatable sections (e.g., multiple phone numbers, addresses):

array-fields.tsx
6 collapsed lines
// useFieldArray for repeatable sections
import { useFieldArray, useFormContext } from "react-hook-form";
interface PhoneEntry {
type: "home" | "work" | "mobile";
number: string;
}
function PhoneNumbers() {
const { control } = useFormContext();
const { fields, append, remove, move } = useFieldArray({
control,
name: "phones"
});
return (
<div>
{fields.map((field, index) => (
<div key={field.id}> {/* Use field.id, not index */}
<SelectField
name={`phones.${index}.type`}
options={["home", "work", "mobile"]}
/>
<TextInput name={`phones.${index}.number`} />
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ type: "mobile", number: "" })}
>
Add Phone
</button>
</div>
);
}

Why field.id not index: React Hook Form generates stable IDs. Using array index as key causes incorrect field associations when items are reordered or removed.

Deep validation for complex nested structures:

nested-validation.ts
2 collapsed lines
// Nested array validation with Zod
import { z } from "zod"
const orderSchema = z
.object({
customer: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
items: z
.array(
z.object({
productId: z.string(),
quantity: z.number().min(1),
options: z
.array(
z.object({
name: z.string(),
value: z.string(),
}),
)
.optional(),
}),
)
.min(1, "Order must have at least one item"),
// Cross-field: total validation
})
.refine((order) => order.items.reduce((sum, item) => sum + item.quantity, 0) <= 100, {
message: "Maximum 100 items per order",
path: ["items"],
})

Arrays with 100+ items require optimization:

TechniqueDescriptionImpact
VirtualizationRender only visible rowsHigh
MemoizationReact.memo on row componentMedium
Batch operationsreplace() instead of multiple append()Medium
Isolated statePer-row components with own stateHigh
optimized-array.tsx
10 collapsed lines
// Memoized array item for performance
import { memo } from "react";
import { useFormContext } from "react-hook-form";
interface ItemRowProps {
index: number;
onRemove: () => void;
}
const ItemRow = memo(function ItemRow({ index, onRemove }: ItemRowProps) {
const { register } = useFormContext();
return (
<div>
<input {...register(`items.${index}.name`)} />
<input {...register(`items.${index}.quantity`)} type="number" />
<button type="button" onClick={onRemove}>Remove</button>
</div>
);
});
// Parent: use field.id as key, not index
{fields.map((field, index) => (
<ItemRow
key={field.id}
index={index}
onRemove={() => remove(index)}
/>
))}

Prevent data loss without overwhelming storage:

autosave.ts
8 collapsed lines
// Debounced autosave to IndexedDB
import { useEffect, useCallback } from "react"
import { useWatch } from "react-hook-form"
import debounce from "lodash.debounce"
import { openDB } from "idb"
const DRAFT_DB = "form-drafts"
const DRAFT_STORE = "drafts"
async function saveDraft(formId: string, data: unknown) {
const db = await openDB(DRAFT_DB, 1, {
upgrade(db) {
db.createObjectStore(DRAFT_STORE)
},
})
await db.put(
DRAFT_STORE,
{
data,
savedAt: Date.now(),
},
formId,
)
}
function useAutosave(formId: string, control: Control) {
const formData = useWatch({ control })
// Debounce saves to every 2 seconds of inactivity
const debouncedSave = useCallback(
debounce((data: unknown) => {
saveDraft(formId, data)
}, 2000),
[formId],
)
useEffect(() => {
debouncedSave(formData)
return () => debouncedSave.cancel()
}, [formData, debouncedSave])
}

Why IndexedDB over localStorage:

FeaturelocalStorageIndexedDB
Storage limit5MB50% of disk
APISynchronous (blocks)Async
Data typesStrings onlyAny (including blobs)
StructureKey-valueObject stores with indexes

For forms with file uploads or large datasets, IndexedDB is the only viable option.

Restore drafts on page load with user confirmation:

draft-recovery.ts
10 collapsed lines
// Draft recovery with staleness check
import { useEffect, useState } from "react"
import { openDB } from "idb"
interface DraftData {
data: unknown
savedAt: number
}
const STALE_THRESHOLD = 24 * 60 * 60 * 1000 // 24 hours
async function loadDraft(formId: string): Promise<DraftData | null> {
const db = await openDB(DRAFT_DB, 1)
const draft = await db.get(DRAFT_STORE, formId)
if (!draft) return null
// Check if draft is stale
const age = Date.now() - draft.savedAt
if (age > STALE_THRESHOLD) {
await db.delete(DRAFT_STORE, formId)
return null
}
return draft
}
function useDraftRecovery(formId: string, reset: UseFormReset) {
const [hasDraft, setHasDraft] = useState(false)
const [draftAge, setDraftAge] = useState<string>("")
useEffect(() => {
loadDraft(formId).then((draft) => {
if (draft) {
setHasDraft(true)
setDraftAge(formatRelativeTime(draft.savedAt))
}
})
}, [formId])
const restoreDraft = async () => {
const draft = await loadDraft(formId)
if (draft) {
reset(draft.data)
setHasDraft(false)
}
}
const discardDraft = async () => {
const db = await openDB(DRAFT_DB, 1)
await db.delete(DRAFT_STORE, formId)
setHasDraft(false)
}
return { hasDraft, draftAge, restoreDraft, discardDraft }
}
clear-on-submit.ts
async function onSubmit(data: FormData) {
await submitToServer(data)
// Clear draft only after successful submission
const db = await openDB(DRAFT_DB, 1)
await db.delete(DRAFT_STORE, formId)
}

For forms with 100+ fields:

1. Virtualization:

virtualized-form.tsx
5 collapsed lines
// Virtualized form fields with react-window
import { FixedSizeList } from "react-window";
interface VirtualFieldListProps {
fields: SchemaField[];
}
function VirtualFieldList({ fields }: VirtualFieldListProps) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
{renderField(fields[index])}
</div>
);
return (
<FixedSizeList
height={600}
itemCount={fields.length}
itemSize={80}
width="100%"
>
{Row}
</FixedSizeList>
);
}

2. Section-Based Loading:

lazy-sections.tsx
8 collapsed lines
// Lazy load form sections
import { lazy, Suspense } from "react";
const PersonalInfoSection = lazy(() => import("./sections/PersonalInfo"));
const EmploymentSection = lazy(() => import("./sections/Employment"));
const FinancialSection = lazy(() => import("./sections/Financial"));
function MultiSectionForm() {
const [activeSection, setActiveSection] = useState(0);
const sections = [
{ Component: PersonalInfoSection, title: "Personal" },
{ Component: EmploymentSection, title: "Employment" },
{ Component: FinancialSection, title: "Financial" }
];
const { Component } = sections[activeSection];
return (
<Suspense fallback={<SectionSkeleton />}>
<Component />
</Suspense>
);
}

3. Validation Optimization:

optimized-validation.ts
// Validate only changed fields, not entire form
const { trigger } = useFormContext();
// On blur, validate only this field
<input
{...register("email")}
onBlur={() => trigger("email")}
/>;
// On submit, validate all
const onSubmit = handleSubmit(async (data) => {
const isValid = await trigger(); // All fields
if (isValid) {
await submit(data);
}
});
performance-profiling.ts
4 collapsed lines
// Performance monitoring for forms
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === "longtask" && entry.duration > 50) {
console.warn("Long task during form interaction:", {
duration: entry.duration,
attribution: entry.attribution,
})
}
}
})
observer.observe({ entryTypes: ["longtask"] })

Performance targets:

MetricTargetDegraded
Keystroke response< 16ms> 50ms
Field blur validation< 50ms> 200ms
Form submission< 100ms (client)> 500ms

Use ARIA live regions to announce validation errors to screen readers:

accessible-errors.tsx
5 collapsed lines
// Accessible error announcements
interface ErrorMessageProps {
fieldId: string;
error?: string;
}
function ErrorMessage({ fieldId, error }: ErrorMessageProps) {
// Pre-register live region in DOM (empty)
// VoiceOver on iOS requires aria-atomic
return (
<div
id={`${fieldId}-error`}
role="alert"
aria-live="polite"
aria-atomic="true"
>
{error && <span className="error-text">{error}</span>}
</div>
);
}
// Field links to error via aria-describedby
function TextField({ name, label, error }: TextFieldProps) {
return (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
aria-describedby={error ? `${name}-error` : undefined}
aria-invalid={Boolean(error)}
/>
<ErrorMessage fieldId={name} error={error} />
</div>
);
}

Why aria-live="polite" over "assertive": Assertive interrupts screen readers immediately—use only for critical errors. Polite waits for a pause in reading, which is less disruptive for typical validation feedback.

Common mistake: Adding role="alert" AND aria-live="assertive" causes double announcements in some screen readers. role="alert" implies aria-live="assertive".

Move focus to first error on failed submission:

focus-management.ts
3 collapsed lines
// Focus first error field on submit
import { useForm } from "react-hook-form"
const {
handleSubmit,
setFocus,
formState: { errors },
} = useForm()
const onSubmit = handleSubmit(
(data) => submitToServer(data),
(errors) => {
// Focus first field with error
const firstErrorField = Object.keys(errors)[0]
if (firstErrorField) {
setFocus(firstErrorField)
}
},
)

When fields appear/disappear, manage focus appropriately:

conditional-focus.tsx
8 collapsed lines
// Focus management for conditional fields
import { useEffect, useRef } from "react";
function ConditionalField({ visible, children }: ConditionalFieldProps) {
const containerRef = useRef<HTMLDivElement>(null);
const wasVisible = useRef(visible);
useEffect(() => {
// Field just appeared
if (visible && !wasVisible.current) {
const firstInput = containerRef.current?.querySelector("input, select, textarea");
if (firstInput instanceof HTMLElement) {
// Delay focus to ensure DOM is ready
requestAnimationFrame(() => firstInput.focus());
}
}
wasVisible.current = visible;
}, [visible]);
if (!visible) return null;
return <div ref={containerRef}>{children}</div>;
}

Support expected keyboard patterns:

KeyAction
TabMove to next field
Shift+TabMove to previous field
EnterSubmit form (if not in textarea)
EscapeClose dropdowns/modals
Arrow keysNavigate options in select/radio
keyboard-submit.tsx
// Prevent accidental submit in multi-field forms
<form
onKeyDown={(e) => {
if (
e.key === "Enter" &&
e.target instanceof HTMLInputElement &&
e.target.type !== "submit"
) {
e.preventDefault();
}
}}
>

Architecture:

  • One question at a time (reduces cognitive load)
  • Logic jumps based on previous answers
  • Microservices: Create, Collect, Connect, Conclude colonies

Key insight: Typeform separates form building from response collection. The Create API enables programmatic form generation—schemas are stored centrally and rendered by lightweight clients.

Trade-off accepted: Limited field visibility (only current question) prevents users from reviewing all questions upfront.

Architecture:

  • 12 question types covering 95% of use cases
  • Real-time response aggregation with charts
  • Tight Google Sheets integration for analysis

Key insight: Intentional simplicity. No conditional logic initially (added later). Focus on collaboration and response visualization rather than form complexity.

Trade-off accepted: Limited customization—no CSS control, minimal branding.

Architecture:

  • Drag-and-drop builder generates JSON Schema
  • Schema and API generated simultaneously
  • Multiple renderers (React, Angular, Vue, vanilla)

Key insight: The schema IS the form. Store JSON in your database, render anywhere. Backend validation uses the same schema.

Trade-off accepted: Complexity of self-hosting. Enterprise pricing for advanced features.

Why this combination dominates:

  1. Uncontrolled by default — Minimal re-renders
  2. Type inference — Schema defines types
  3. Resolver pattern — Pluggable validation
  4. Small bundle — ~9kb combined
rhf-zod-integration.ts
4 collapsed lines
// The modern form stack
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
type FormData = z.infer<typeof schema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
return (
<form onSubmit={handleSubmit((data) => login(data))}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
{/* ... */}
</form>
);
}

Form builder architecture centers on three decisions:

  1. Schema format — JSON Schema for portability, Zod/TypeBox for TypeScript-first, XState for complex flows
  2. State approach — Uncontrolled with subscriptions for performance (React Hook Form), controlled for simpler mental model (Formik)
  3. Validation timing — onBlur as default, debounced onChange for async checks, onSubmit for simple forms

For production form builders:

  • < 50 fields: Any approach works. Optimize for developer experience.
  • 50-200 fields: Subscription-based state, section lazy loading, consider virtualization.
  • > 200 fields: Mandatory virtualization, isolated field components, aggressive memoization.

The schema-driven approach (JSON Schema or Zod) enables the holy grail: define once, validate everywhere, render anywhere.

  • React or equivalent component model
  • TypeScript (for type-safe schema approaches)
  • Familiarity with controlled/uncontrolled component patterns
  • Form builders separate schema (what) from rendering (how) through component registries
  • Uncontrolled inputs with subscriptions minimize re-renders for large forms
  • Zod’s superRefine enables complex cross-field validation with precise error paths
  • IndexedDB provides robust draft storage; localStorage has 5MB limit
  • Virtualization is mandatory for forms exceeding 100-200 fields
  • ARIA live regions (aria-live="polite") announce errors without disrupting screen reader flow
  • Focus management on error and conditional field appearance improves accessibility

Read more

  • Previous

    Design a Drag and Drop System

    System Design / Frontend System Design 26 min read

    Building drag and drop interactions that work across input devices, handle complex reordering scenarios, and maintain accessibility—the browser APIs, architectural patterns, and trade-offs that power production implementations in Trello, Notion, and Figma.Drag and drop appears simple: grab an element, move it, release it. In practice, it requires handling three incompatible input APIs (mouse, touch, pointer), working around significant browser inconsistencies in the HTML5 Drag and Drop API, providing keyboard alternatives for accessibility, and managing visual feedback during the operation. This article covers the underlying browser APIs, the design decisions that differentiate library approaches, and how production applications solve these problems at scale.

  • Next

    Design a Data Grid

    System Design / Frontend System Design 19 min read

    High-performance data grids render thousands to millions of rows while maintaining 60fps scrolling and sub-second interactions. This article explores the architectural patterns, virtualization strategies, and implementation trade-offs that separate production-grade grids from naive table implementations.The core challenge: browsers struggle with more than a few thousand DOM nodes. A grid with 100,000 rows and 20 columns would create 2 million cells—rendering all of them guarantees a frozen UI. Every major grid library solves this through virtualization, but their approaches differ significantly in complexity, flexibility, and performance characteristics.