Design a drag-and-drop form builder
An editor where people who don't write code build forms by dragging fields onto a canvas, plus a renderer that shows those forms to thousands of respondents: the hard parts are the form definition as versioned data, one field registry shared by editor and renderer, validation and show/hide logic expressed as data instead of code, and publishing new versions without breaking the answers already collected.
The question
Design a drag-and-drop form builder for our product. Our customers are HR teams, event organisers and small shops. They should build a form without writing code: drag fields from a palette, set labels and options, add validation and rules like "only show this question if the answer above is Yes", preview it, and publish a link.
Then strangers fill in that form on their phones, and the owner sees the answers in a table and exports them to CSV. Walk me through how a form is stored, how the editor and the public form stay in sync, validation and conditional logic, publishing changes to a live form, the respondent experience, and security.
Real-life examples: Typeform, Google Forms, Jotform, Tally, SurveyMonkey, Form.io and SurveyJS, which teams embed to build internal form builders
Requirements: two apps that share one definition
A form builder is really two products joined by one file. The first is the builder: an editor where a form owner drags fields onto a canvas, types labels, sets rules and presses Publish. The second is the renderer: the public page where a respondent (the person answering) fills in the form and presses Submit.
The file between them is the form definition: a JSON document that says which fields exist, in which order, with which rules. The builder writes it; the renderer reads it. Almost every hard decision in this guide is about that file.
Several other guides cover pieces of this problem, and we point to them instead of repeating them. The admin CMS guide shows how to generate UI from a schema, and how drafts and publishing work for content. The multi-step form guide covers wizard state, step navigation and validation UX for a long form.
The Kanban board guide covers drag-and-drop mechanics, the undo and redo guide covers command history, and the file uploader guide covers uploads. Here we go deep on what only a form builder has: a definition that strangers' answers depend on, a registry of field types, rules written by non-developers, and live forms that change while people are answering them.
Questions to ask
Each question below changes a decision later in the guide. The middle column is what we assume.
| Question | Answer we assume | What it changes |
|---|---|---|
| Who builds forms? | Non-developers: HR staff, event organisers, shop owners. Some customers are big companies with developers. | Rules must be clicked together, never typed as code. Big customers want custom field types, so we need a plugin point. |
| How big do forms get? | Most have 5 to 30 fields. The largest (job applications, audits) reach 150 fields over 10 pages. | The renderer must stay fast at 150 fields, and the builder needs sections, pages and multi-select. |
| What logic do owners need? | Show or hide a field, skip to a page, required only if another answer is set, simple cross-field checks. | Logic is data with a small grammar, checked for cycles and dangling references at edit time. |
| Can a form change after it goes live? | Yes, often: typos, new questions, removed questions, while answers keep arriving. | Draft and published versions, responses tied to a version, and warnings for breaking edits. |
| How many responses? | A popular form gets 50,000 responses in a day; a peak of about 20 submits per second for one form. | Submit must be cheap and idempotent; the results table pages from the server; export runs as a job. |
| Who answers, on what? | Anyone with the link. About 70% on phones, some on slow networks. | A small, fast renderer bundle, mobile input types, and spam protection on a public endpoint. |
| Does more than one person edit a form? | Sometimes two or three teammates, rarely at the same second. | Soft locks or optimistic concurrency, not a full real-time editor on day one. |
| Accessibility standard? | WCAG 2.2 AA for both the builder and the generated forms. | Keyboard alternatives to every drag, and generated forms with real labels, fieldsets and an error summary. |
Functional requirements
- Builder: a palette of field types, a canvas, and a properties panel. Add fields by dragging or by keyboard, reorder them, group them into sections and pages, duplicate and delete them.
- Configure each field: label, help text, placeholder, options for choice fields, default value, and validation rules (required, length, range, pattern, cross-field checks).
- Conditional logic: show or hide fields and sections, and skip to a page, based on earlier answers.
- Undo and redo for every edit, and autosave of the draft every few seconds.
- Preview the form as a respondent, on a phone-sized frame, and test it with fake answers.
- Publish: the draft becomes a new published version with a public link. Later edits stay in the draft until the next publish.
- Renderer: one page or many pages, client validation, partial saves, file uploads, and a submit that happens once.
- Results: a table of responses, filters, and CSV export with stable columns across versions.
Non-functional requirements
- Never lose an answer. A response must always be readable, even after the owner deletes or renames the question it answered.
- Editor and renderer never drift. What the owner sees in the preview is exactly what the respondent sees.
- Fast public forms. The renderer's JavaScript stays under about 60 KB gzipped for common field types; Largest Contentful Paint under 2.5 seconds on a mid-range phone; typing in any field paints within 100 ms even with 150 fields. That is our target for Interaction to Next Paint (INP, the time from a key press or tap to the next frame), half of the 200 ms that counts as good.
- Responsive builder. Dragging runs at 60 frames per second on a 150-field form; an edit shows in the preview within one frame.
- Safe. No owner-written code ever runs in a respondent's browser, labels can't inject script, and public submit endpoints resist bots.
- Accessible. Every builder action works with a keyboard and a screen reader, and every generated form passes WCAG 2.2 AA without the owner knowing what that is.
Out of scope
- Payments inside forms: they bring PCI rules and a checkout flow of their own (see the checkout flow guide).
- Analytics dashboards over responses beyond a table and CSV export.
- Full real-time co-editing with live cursors: we explain how we would add it, but start with something simpler.
- Theming beyond a logo, a brand color and a font choice.
Check your understanding · Exercise 1
Which answers from the interviewer change the design the most? Select all that apply.
What makes a form builder hard
A form looks like the easiest UI there is: labels, inputs, a button. A form builder is hard because it moves every decision a developer would make in code into data that a non-developer edits, and then strangers depend on that data. Five properties drive everything that follows.
- The UI is data, written by someone else. The developer of the renderer never sees the forms it will show. A field could have a 2,000-character label, 400 dropdown options, or a regex that hangs. The renderer must handle any definition the builder can produce, and the builder must refuse definitions the renderer can't handle.
- Two apps must agree forever. The builder preview and the public renderer show the same definition. If they render a field differently, or validate it differently, owners publish forms that behave in ways they never tested. Drift between them is the classic bug of this product.
- The data outlives the form. A response collected in March must still be readable in December, after the owner renamed questions, deleted some and turned a text field into a dropdown. Answers point at the form, so the form's shape can't simply change in place.
- Logic without code. "Show the dietary question only if Attending is Yes" is a tiny program. Owners write dozens of these, and they can contradict each other: field A shows B, B shows A. We need a grammar small enough to check and evaluate safely on both the client and the server.
- Public, anonymous, mobile traffic. The renderer runs on strangers' phones, on any network, and its submit endpoint is open to the internet. Bots will find it. Labels written by the owner are shown to respondents, so they are an injection path.
The figure below follows one form through its life. Notice the moment that causes most of the trouble: after version 1 is published and answers arrive, the owner keeps editing. From then on, the form is not one thing but a series of versions, and every answer has to say which one it belongs to.
Think first
Suppose the builder preview and the public form are two separate React apps written by two teams. What bug would you expect to see first?
Drift. One team adds a new option to the date field, say "no dates in the past", to the renderer but forgets the preview, or the preview checks a regex with one engine and the server with another. Owners test in the preview, it works, they publish, and respondents can't submit.
The fix isn't more testing between two apps. It's one implementation of each field type, used by both. That is the field registry in chapter 4.
The form definition is data
Everything starts with the shape of the definition. Get it right and the builder, the renderer, the server validator and the CSV exporter are all small programs that read the same JSON. Get it wrong and every feature needs a special case. Three questions shape it: which format, how fields are identified, and how the format itself changes over the years.
The naive definition and where it breaks
The first version most teams write is a map of fields keyed by their label: { "Full name": "text", "Email": "email" }, and responses stored the same way: { "Full name": "Ada" }. It works for a week.
Then the owner fixes a typo, "Full name" becomes "Full Name", and 3,000 old responses no longer match any question. The results table shows two columns, half empty each. Worse, two questions can have the same label ("Comments" on page 2 and page 5), and their answers overwrite each other.
The lesson: a field needs an identity that has nothing to do with what it says. We give every field a random, immutable field id like f_7Kq2mX when it's created. Labels, order, page and even type can change; the id never does, and every answer, rule and export column points at the id.
Decision Which format should the form definition use?
What we pick: We pick our own UI-first definition, with JSON Schema generated from it. A form builder's main job is editing layout, order and logic, which JSON Schema doesn't model, and two parallel documents double the work of every edit. We still get JSON Schema for free at the edges where it shines: describing the response in our public API.
The shape, field by field
type FormDefinition = {
formatVersion: 3; // shape of this JSON, for migrations
formId: string; // "frm_x81Lk"
title: string;
pages: Page[];
settings: { progressBar: boolean; submitLabel: string };
};
type Page = { id: string; title?: string; sections: Section[]; logic?: PageLogic };
type Section = { id: string; title?: string; fieldIds: string[]; showIf?: Condition };
type Field = {
id: string; // "f_7Kq2mX": random, never reused
key: string; // "full_name": export column, owner can edit
type: string; // "text" | "email" | "choice" | ... | "acme.iban"
label: string;
help?: string;
props: Record<string, unknown>; // per type: options, rows, accept...
rules: Rule[]; // validation as data (chapter 6)
showIf?: Condition; // logic as data (chapter 7)
};
type FormDraft = { definition: FormDefinition; fields: Record<string, Field> };
Three details matter. First, fields live in a flat map by id, and sections hold an ordered list of field ids. Moving a field between pages is then two array edits, not a deep copy, and logic can find any field in one lookup. This is the same normalized shape (each item stored once and referenced by id) that the tree view guide uses for nested folders.
Second, there are two ids: id is for machines and never changes; key is a readable name for exports and integrations, which the owner may edit, with a warning. Third, formatVersion is the version of the JSON shape itself, not of this form.
f_a, so a rename or a move changes nothing they depend on.When the format itself changes
In two years the definition format will change: maybe options becomes an array of objects with ids instead of strings, so translated options keep their identity. Millions of stored definitions are in the old shape, and we don't rewrite them all in one night.
Instead, the loader runs upcasters: small pure functions, one per format step (v1 to v2, v2 to v3), applied in order when a definition is read. The builder always edits the newest format, and saves it back in that format. The server owns the same upcasters, so a definition looks the same wherever it's loaded.
Check your understanding · Exercise 2
An owner renames the question "Phone" to "Mobile number" on a live form with 8,000 responses. With our definition, what happens? Select all that apply.
The field registry: one implementation per field type
A form builder ships 15 to 30 field types: short text, long text, email, number, phone, date, single choice, multiple choice, dropdown, rating, scale, yes/no, file upload, signature, matrix, and layout-only blocks like a heading or a paragraph. Each type needs the same five things: an icon and name for the palette, a properties panel in the builder, a renderer for respondents, a validator, and a way to turn its value into stored JSON and CSV cells.
The naive code spreads these across the app as switch (field.type) statements: one in the canvas, one in the preview, one in the public renderer, one in the server validator, one in the exporter. Adding a "rating" field means editing five switches in three repos, and forgetting one is how a rating renders in the preview but fails validation on the server.
Decision How should field types be organised?
What we pick: We pick a registry of field type modules, each split into a pure core (shared by browser and server) and a view (React, browser only). It's the only option that makes drift impossible by construction: there is exactly one validator and one renderer per type. Web components remain an option for the views later, if we need to embed forms in non-React sites.
// packages/fields-core: runs in the browser AND on the server
interface FieldCore<P, V> {
type: string; // "rating"
contractVersion: 1;
defaults(): P; // { max: 5, icon: "star" }
propsSchema: PropSpec[]; // drives the properties panel
emptyValue: V | null;
validate(value: V | null, props: P): Issue[]; // pure, no I/O
toStored(value: V): Json; // what goes in the response
toCsv(value: V | null, props: P): string[]; // one or more cells
csvHeaders(field: Field): string[];
}
// packages/fields-ui: browser only
interface FieldView<P, V> {
type: string;
palette: { label: string; icon: IconName; group: "basic" | "choice" | "advanced" };
Render: ComponentType<RenderProps<P, V>>; // respondent input
Editor?: ComponentType<EditorProps<P>>; // custom panel, rare
}
const registry = new Map<string, { core: FieldCore<any, any>;
view: () => Promise<FieldView<any, any>> }>();
Most types never need a custom Editor. Their properties panel is generated from propsSchema, a list like [{ name: "max", kind: "number", min: 3, max: 10 }]: the same config-driven UI idea the admin CMS guide uses for content types (see the config-driven UI lesson). A custom editor is only for complex props, like the matrix field's grid of rows and columns.
The view is a function that returns a promise, so each type's React code is its own chunk. A public form with only text and choice fields never downloads the signature pad's canvas drawing code or the date picker's calendar. The renderer reads the definition, collects the distinct type values, and starts loading those chunks in parallel with the first paint of the form shell.
field.type in the same registry. The browser parts use the view; the server parts use the pure core. There is no second implementation that could drift.What the renderer hands every field
The RenderProps are where accessibility is won or lost, so the renderer, not each field, owns the parts every field needs. It creates the label (or legend for groups), the help text with an id, the error message with an id, and passes inputId, describedBy and invalid down. A field type only renders its control and wires those ids to it. A plugin author who forgets about aria-describedby still ships an accessible field, because the hard parts were never theirs to forget (see the component API design lesson for this kind of contract).
Custom field types from customers
Big customers ask for types like "IBAN", "employee picker" or "product SKU". Most need no code: an IBAN is a text field with a pattern, a length rule and an input mask, so we offer presets, saved fields with locked props that appear in the palette.
The rest, like an employee picker that calls the customer's directory, are real plugins. We never run customer JavaScript on our forms origin: such plugins run in a sandboxed iframe on a separate origin and talk through postMessage, or exist only in the self-hosted SDK, where the customer runs the renderer on their own site. Our server never runs their code either: a plugin declares the shape of its value and its rules (type, length, pattern) in a manifest, and the server validates answers against that.
Check your understanding · Exercise 3
A team adds a "rating" field type. With the registry design, which statements are true? Select all that apply.
The builder: canvas, commands and autosave
Most form builders converge on the same three-column layout, because it maps to the three things an owner does: choose a field type (the palette on the left), place and order fields (the canvas in the middle, which looks like the real form), and configure the selected field (the properties panel on the right). A top bar holds the form title, the save status, Undo and Redo, Preview and Publish. Below 900 pixels wide the side panels become drawers, but serious form building happens on a laptop, and we optimise for that.
The canvas is not a static picture. Each field on it is the real Render component from the registry, wrapped in an editing frame that adds a drag handle, a selection outline and a small toolbar (duplicate, delete, move). Clicks on the input inside are blocked in edit mode, so clicking a dropdown selects the field instead of opening the dropdown. This is the main reason the canvas and the live form never look different: the canvas is the live form, with a frame around each field.
Two kinds of drag
A form builder has two drags that look alike but mean different things. Dragging from the palette creates a field: nothing exists until the drop, and a cancelled drag must leave no trace. Dragging on the canvas moves an existing field, possibly across sections and pages.
Both need a drop indicator: a 2-pixel line between two fields that shows exactly where the field will land. The indicator position comes from the pointer's y coordinate compared with the vertical midpoint of the field under it: above the midpoint means "before", below means "after". Empty sections show a dashed drop zone, because there is no field to compare with.
Decision Which drag-and-drop engine should the builder use?
What we pick: We use pointer events through a drag library for everything inside the builder, and keep native drag and drop only for files dropped from the desktop. The Kanban board guide covers the mechanics (sensors, collision detection, auto-scroll and the overlay) in depth; what's specific here is the nesting of pages, sections and fields, and that a palette drop creates rather than moves.
Every drag has a keyboard twin
WCAG 2.2 added success criterion 2.5.7, Dragging Movements: anything done by dragging must also be possible with single pointer actions without dragging. Success criterion 2.1.1 already requires that all of it works from a keyboard. So every drag has a non-drag twin.
Each palette item is a button: pressing it (Enter, Space or a click) inserts the field after the currently selected one, selects it, and moves focus to its label input in the properties panel. Each field's toolbar has Move up and Move down buttons (Alt+Arrow Up and Alt+Arrow Down) and a "Move to page" menu for long jumps. After a move, a polite live region (an element whose changes screen readers read aloud) announces "Email moved to position 4 of 12 on page 2". The keyboard navigation and ARIA patterns lessons cover the details.
The canvas itself is a list of fields, so it behaves like a listbox for selection: arrow keys move the selection, Enter opens the field's properties, Delete removes it (undoable), and Escape returns focus to the canvas from the panel. Shortcuts must never fire while the owner is typing in a text input; the handler checks the event target before acting.
Multi-select, sections and pages
A 150-field audit form is edited in blocks, not one field at a time. Shift+click selects a range, Ctrl+click (Cmd+click on a Mac) toggles one field. With several fields selected, the toolbar offers Group into section, Move to page, Duplicate and Delete, and the properties panel shows only the props they share, such as Required.
Dragging a selection moves all of them, keeping their relative order, and the overlay shows "3 fields". Grouping wraps the selected fields in a new section with its own id; the section can then carry a title and a single show/hide condition for the whole group, which is far easier for owners than repeating the same condition on 8 fields.
Try it in the lab below. Add fields, move them, change a field's type and label, and mark one as required. Watch three views update together: the JSON definition, the preview, and validation of a test answer. Notice that the field ids never change, whatever you edit.
Check your understanding · Exercise 4
A keyboard-only owner wants to add an Email field after field 3 and then move it to page 2. Which design choices make that possible? Select all that apply.
Edits as commands: undo, redo and autosave
Every change an owner makes, from a drop to a single keystroke in a label, goes through one door: a command dispatched to the draft store, such as addField, moveFields, updateField, setRule, groupIntoSection or deleteFields. The store applies the command to the draft with a pure function, and everything else (canvas, properties panel, preview, logic checker, autosave) reacts to the new draft. No component edits the definition directly. This single door is what makes undo, autosave and, later, collaboration possible.
The undo and redo guide covers command history in depth: inverse operations versus snapshots, history limits, and grouping. Three things are specific to a form builder. First, typing coalesces: editing a label from "Name" to "Full name" takes several keystrokes but is one undo step, so consecutive updateField commands on the same field and prop merge until 1 second of idle or a blur.
Second, deletes must restore everything: deleting a field that 3 rules reference removes those rules too (see conditional logic below), and one Undo must bring back the field, its position, and the rules, as one unit. Third, undo restores selection: after an undo the changed field is selected and scrolled into view, otherwise the owner doesn't know what changed on a 150-field canvas.
type Command =
| { kind: "addField"; field: Field; sectionId: string; index: number }
| { kind: "moveFields"; ids: string[]; toSection: string; index: number }
| { kind: "updateField"; id: string; patch: Partial<Field> }
| { kind: "deleteFields"; ids: string[] }
| { kind: "setRule"; id: string; rules: Rule[] }
| { kind: "groupIntoSection"; ids: string[]; section: Section };
enablePatches(); // once at startup: Immer 6+ needs it for patches
function dispatch(cmd: Command) {
const [next, patches, inverse] = produceWithPatches(draft, (d) => apply(d, cmd));
history.push({ cmd, patches, inverse, selection: selectionOf(cmd) }); // may merge
draft = next;
autosave.markDirty();
}
Immer's produceWithPatches returns the new draft, the patches that describe the change, and the inverse patches that reverse it. Undo applies the inverse patches with applyPatches, redo applies the patches again, and the draft stays an immutable object. Immutability matters for speed: each field component on the canvas is memoised on its own Field object, so editing one label re-renders one field, not 150.
Autosave without losing or overwriting work
Owners expect Google Docs behavior: no Save button, and nothing lost when the laptop dies. A 150-field definition is about 90 KB of JSON, about 10 KB after gzip. That is small enough to send whole, which keeps the server simple: it validates the full draft each time.
Decision How should the draft be saved?
What we pick: We start with debounced full-draft saves guarded by a revision number. It gives us autosave and conflict detection with almost no server logic, and the commands we already have can become the sync protocol later if co-editing becomes a requirement (chapter 12). Between saves, the latest draft is also written to IndexedDB, so a crash before the network call loses nothing.
The save status in the top bar is a small state machine, shown below. The interesting transition is an edit that arrives while a save is in flight: we don't cancel the request (it may already have reached the server), we mark the draft dirty again and save once more after the reply, with the new revision. A beforeunload listener warns only while the state is Dirty, Saving or Error.
412 means a teammate saved first.The sequence below follows one drop from the palette through the store, the re-render and the save, including the failure path when a teammate saved first.
412, the client doesn't overwrite and doesn't discard: it fetches the newer draft and replays its own commands on it.Check your understanding · Exercise 5
An owner types a new label while a save request is in flight. What should the client do? Select all that apply.
Validation rules as data
Owners need rules like "required", "between 18 and 99", "must look like a UK postcode", and occasionally a rule across fields: "end date after start date", "at least one contact method". The rule must run in the respondent's browser for fast feedback and again on the server, because anyone can send a POST without our page. Both runs must reach the same answer, for the same input, forever.
The tempting shortcut is a "custom validation" box where the owner types JavaScript, like value => value.endsWith("@acme.com"), which we run with new Function. That one box turns every form into a script injection vector: an owner (or an attacker who takes over an owner's account) writes code that reads every answer and sends it to their server, runs in every respondent's browser on our origin, and, if we run it on the server too, executes arbitrary code in our backend.
It also forces unsafe-eval into our Content Security Policy (CSP, the response header that limits which scripts a page may run). Google Forms, for example, offers number, text, length and regular expression rules, not code, and so will we.
Decision How should owners express validation and cross-field rules?
What we pick: We use fixed rule kinds plus JSON Logic conditions, built through menus. Single-field rules cover about nine in ten cases and come with good error messages. JSON Logic covers cross-field rules and, in the next chapter, show/hide logic, with one evaluator and one grammar. The language is small enough that we can analyse it, which owner-written code never allows.
{
"id": "f_end", "type": "date", "label": "Last day",
"rules": [
{ "kind": "required" },
{ "kind": "custom",
"check": { ">": [{ "var": "f_end" }, { "var": "f_start" }] },
"message": "The last day must be after the first day." }
]
}
{
"id": "f_zip", "type": "text", "label": "Postcode",
"rules": [{ "kind": "pattern", "regex": "^[A-Z]{1,2}[0-9][A-Z0-9]? ?[0-9][A-Z]{2}$",
"flags": "i", "message": "Enter a UK postcode, like SW1A 1AA." }]
}
Two details keep these rules honest. A custom check runs only when the fields it reads have values, so a blank first day reports "required" instead of a confusing comparison error. And dates are stored as ISO strings like 2026-03-04, which compare correctly as text in JavaScript; whether every JSON Logic library compares strings that way is exactly the kind of edge case the shared test vectors below pin down.
One evaluator, run in two places
The validator is part of the pure core package from chapter 4: validateResponse(definition, answers) returns a list of { fieldId, code, message } issues. The browser runs it on blur and on submit; the API server runs the exact same function, from the same package version, on every submission. To catch drift between environments, the repository keeps a file of a few hundred test vectors (definition, answers, expected issues), and CI runs it against both the browser build and the server build. If we ever add a Python service, it must pass the same file.
Owner regexes can hang a server
A pattern rule is owner input that we execute. A regex like ^(a+)+$ takes exponential time on a string of a characters followed by ! in a backtracking engine like JavaScript's: each extra a roughly doubles the time, so 30 of them take seconds. This is ReDoS (regular expression denial of service). In the browser it only freezes that respondent's own tab; on a Node server it blocks the event loop and every other request with it.
Three defenses: limit patterns to 200 characters; on the server, run patterns with RE2, Google's linear-time engine (available in Node through the re2 package), which refuses backreferences and lookarounds; and in the builder, reject patterns RE2 can't compile, at edit time, so the client and server never disagree about which patterns are legal. Most owners never write a regex anyway: they pick a preset like "UK postcode" or "digits only".
Check your understanding · Exercise 6
Why store cross-field rules as JSON Logic instead of a JavaScript snippet? Select all that apply.
Conditional logic and the dependency graph
Conditional logic is what turns a list of questions into a conversation. An event RSVP asks "Attending?"; only a Yes reveals "Dietary needs" and "Plus one?"; only a Yes to Plus one reveals "Guest name". A job application skips the visa page if you're a citizen.
Owners build two kinds of logic: visibility (showIf on a field or a section) and branching (page rules like "if Role is Engineer, go to page 4"). Typeform calls branching rules logic jumps, and Google Forms offers "go to section based on answer". Both kinds use the JSON Logic conditions from the last chapter.
The naive renderer evaluates every condition after every keystroke, in definition order, and hides fields whose condition is false. It breaks in three ways as forms grow. Order matters: field 5 may read field 12, whose own visibility is decided later in the loop, so field 5 uses a value that should already count as hidden, and fields flicker until a second pass.
Hidden fields keep their values: hide "Guest name" by switching Plus one to No, and the old guest name is still submitted and still blocks submit if it's required. And owners create loops: A shows B and B shows A, so neither ever appears, or the evaluator bounces forever. All three come from the same missing piece: we never modelled which field depends on which.
Decision How should the renderer and builder handle logic between fields?
What we pick: We build the dependency graph, and also warn (not block) when a condition points forward. The graph makes every class of logic bug visible at edit time, and for 150 fields and a few hundred edges, rebuilding it after each edit takes well under a millisecond. The "earlier fields only" idea becomes a hint in the condition menu rather than a hard rule.
function varsOf(node: unknown, out = new Set<string>()): Set<string> {
if (Array.isArray(node)) node.forEach((n) => varsOf(n, out));
else if (node && typeof node === "object") {
for (const [op, arg] of Object.entries(node)) {
if (op === "var") out.add(String(Array.isArray(arg) ? arg[0] : arg));
else varsOf(arg, out);
}
}
return out;
}
// edges: controller id -> ids it controls
function findCycle(edges: Map<string, string[]>): string[] | null {
const state = new Map<string, "visiting" | "done">();
const path: string[] = [];
const visit = (id: string): string[] | null => {
if (state.get(id) === "visiting") return path.slice(path.indexOf(id));
if (state.get(id) === "done") return null;
state.set(id, "visiting"); path.push(id);
for (const next of edges.get(id) ?? []) { const c = visit(next); if (c) return c; }
path.pop(); state.set(id, "done");
return null;
};
for (const id of edges.keys()) { const c = visit(id); if (c) return c; }
return null;
}
The builder runs these checks after every command and shows problems where the owner is looking: a red badge on the rule, a list in a Problems drawer, and Publish disabled with a reason until they're fixed.
The checks are: a cycle ("Attending and Plus one hide each other"); a dangling reference (a rule reads a field that no longer exists); a type mismatch (a rule compares a choice field to an option id that was deleted); an unreachable page (no path of page jumps leads to it); and a backward jump between pages, which could send respondents round the same pages in a loop, so jumps may only go forward.
Hidden means "no value"
The rule that removes most logic bugs: a hidden field has no value, for everything downstream. While evaluating in topological order, a field whose condition is false is treated as empty by every condition that reads it. So if Plus one is No, Guest name is hidden, and a rule "show Guest dietary needs if Guest name is not empty" also hides.
Hidden fields are also skipped by validation (a hidden required field never blocks submit) and stripped from the submission. The renderer keeps their typed values in memory, though, so a respondent who flips Plus one back to Yes gets their guest's name back instead of retyping it.
The server never trusts the client's idea of what was visible. It re-runs the same visibility evaluation from the core package on the submitted answers, drops values for fields that should be hidden, and only then validates. A crafted request that answers a hidden question gets that answer silently discarded, not stored.
Deleting a field that rules depend on
When an owner deletes a field, the graph tells us instantly which rules read it. If none, the delete is silent (and undoable). If some do, a dialog says "Attending is used in 3 rules: Dietary needs, Plus one, page 3 jump. Delete the field and these rules?" with the rule list.
Choosing yes deletes the field and those conditions in one command, so one Undo restores all of it. We never leave a rule pointing at nothing without telling the owner, because a dangling var evaluates to null, and a hidden field that silently never shows is the hardest bug for an owner to notice.
Try it in the logic lab. Add rules between fields, then create a loop, and watch the graph flag the cycle. Delete a field that a rule reads and see the dangling reference. Then fill in answers and see which values are dropped on submit because their fields are hidden.
Check your understanding · Exercise 7
Plus one is set to No, which hides Guest name. The respondent had typed "Sam" into Guest name earlier. What happens on submit? Select all that apply.
Preview, publishing and versions
Owners don't trust a form until they've filled it in. The preview must be the real respondent experience: the same renderer, the same validation, the same logic, at phone and desktop widths. And publishing must turn the draft into something stable that strangers can answer, while the owner keeps editing.
A preview that can't lie
We render the preview inside an iframe that loads the real public renderer bundle from the forms origin, the same file respondents get, and send it the current draft with postMessage after each edit. Three things come from the iframe that an in-page preview can't give. Media queries respond to the iframe's own width, so the phone frame at 390 pixels shows the real mobile layout, not a squeezed desktop one.
The owner's theme CSS and the renderer's styles can't leak into the builder or the other way round. And because it's the real bundle, a renderer bug shows up in the preview before it reaches respondents. The iframe's sandbox attribute allows scripts and forms but not top-level navigation. Without allow-same-origin the frame gets an opaque origin, so its messages arrive with event.origin set to the string "null", and the builder instead checks that event.source is the preview frame's window before trusting a message.
Preview has a Test mode. Each field core can produce a sample value (sample(props): a name for a text field, the first option for a choice field), so "Fill with test answers" completes the form in one click. The owner can then change answers to walk each branch, while a side panel shows which pages were visited, which fields are hidden and why ("hidden because Attending is No"). Test submissions are stored with test: true and excluded from results and exports, so owners can try the real submit without polluting the data.
Draft and published versions
Here is the scenario that decides the model. A job form has been live for two weeks with 1,200 applications. The owner deletes the question "Years of experience", changes "Start date" from a text field to a date field, and adds a required question "Portfolio link". Meanwhile 40 people are halfway through the old form on their phones.
Decision How should edits to a live form reach respondents and existing responses?
What we pick: We use immutable published versions, and never rewrite collected answers. Merging versions is a read-time problem (chapter 10) that we can solve once, correctly, for every form. Migrations would rewrite data people gave us, which is the one thing a form product must never get wrong. The admin CMS guide uses the same draft and published split for content; what is new here is that strangers' answers depend on each version.
Publishing is not just a copy. The builder diffs the draft against the current published version, by field id, and classifies each change before the owner confirms.
| Change | Class | What we do |
|---|---|---|
| Edit label, help text, placeholder | Safe | Publish. Old answers still belong to the same field id. |
| Reorder fields, move to another page | Safe | Publish. Export column order follows the newest version. |
| Add an optional field or a choice option | Safe | Publish. Old responses show it as empty. |
| Add a required field, or make a field required | Careful | Warn: people mid-form on the old version won't have answered it. |
| Remove a field or an option | Breaking | Warn with the count of answers it has. The column stays in results and exports, marked "removed in v4". |
| Change a field's type | Breaking | If the field has answers, the builder creates a new field id and marks the old one removed, so old text answers are never read as dates. |
The last row is the subtle one. A type is part of what an answer means, so a type change on a field with answers is really "remove the old question, add a new one with the same label". Doing that automatically keeps every stored answer readable with its original type, and the results table shows two columns ("Start date (text, v1 to v3)" and "Start date") that the owner can choose to merge in an export.
Respondents already in the middle of version 3 when version 4 publishes keep version 3: their page loaded it, and their partial response records it. The server accepts submissions to a superseded version for a grace period (we use 24 hours) and validates them against that version's definition. After that, or if the owner closes the form, the renderer shows "This form has changed" and offers to carry matching answers over to the newest version by field id.
Check your understanding · Exercise 8
The owner of a live form with 1,200 responses changes "Start date" from a text field to a date field. What should the system do? Select all that apply.
The respondent renderer: fast, accessible, mobile
The renderer is used by millions of strangers, 70% of them on phones, and each one decides in the first few seconds whether to bother. Every slow paint, every unlabelled radio group and every keyboard that shows letters for a phone number costs answers. That is why form products watch completion rate so closely.
Decision How should the public form reach the respondent's browser?
What we pick: We server-render the first page and hydrate. Because a published version never changes, we can even render it once at publish time and cache it at the CDN under a URL that includes the version id, so most respondents get static HTML. The renderer bundle stays small (the core plus the common field views, around 60 KB gzipped) and the rest arrives per field type.
Staying fast at 150 fields
The naive renderer keeps all answers in one React state object at the top: every keystroke re-renders the whole form, and every condition re-evaluates. With 150 fields that's 150 components and a few hundred conditions per keystroke, which on a mid-range Android phone can take tens of milliseconds before layout and paint even start. That uses up most of our 100 ms INP target, and typing feels sticky. Three changes fix it, all covered in more depth in the form state lesson.
- Per-field subscriptions. Answers live in a small store outside React; each field subscribes to its own value (the pattern React Hook Form and
useSyncExternalStoremake easy). Typing in one field re-renders one field. - Recompute only dependents. When field X changes, walk the dependency graph from X and re-evaluate only the conditions downstream, in topological order. A change to a field nothing depends on costs nothing beyond its own render.
- Render one page at a time. Long forms are split into pages by the owner or by our default of about 10 to 20 questions per page. For the rare long single-page form,
content-visibility: autoon sections lets the browser skip layout and paint for off-screen sections. We avoid list virtualization: it breaks browser find-in-page, password manager autofill and screen reader navigation of the form.
Accessible by construction
Owners don't know WCAG, so the renderer must make it impossible to publish an inaccessible form. The rules live in the renderer's frame around each field (chapter 4), not in each field type:
- Every input gets a real
labelelement tied byforandid. Radio and checkbox groups are wrapped infieldsetwith the question aslegend, so a screen reader reads "Dietary needs, group" before "Vegan, radio button, 1 of 4". - Help text and the error message are linked with
aria-describedby; an invalid field getsaria-invalid="true". Required is said in the label text, not only with a red asterisk. - On a failed submit or page change, an error summary at the top of the page receives focus and links to each invalid field. This is the pattern the multi-step form guide works through, and the GOV.UK Design System documents.
- Each field type maps to the right
type,inputmodeandautocompletevalues: an email field getstype="email"andautocomplete="email", a phone fieldtype="tel", so phones show the right keyboard and browsers can autofill. - The builder lints for accessibility too: it blocks publishing an image without alt text, a field with an empty label, or a theme color with text contrast below 4.5 to 1.
On phones, inputs use a font size of at least 16 pixels so iOS Safari doesn't zoom in on focus, and choice options are full-width tap targets well above the 24 by 24 pixel minimum of WCAG 2.2 criterion 2.5.8. A rating or scale field becomes a vertical list below 400 pixels wide, because ten buttons in a row don't fit a thumb.
Partial responses and submit
Long forms lose people halfway. When the owner enables it, the renderer saves a partial response on each page change: PUT /responses/{id} with the answers so far, the version id and a resume token kept in localStorage and optionally emailed as a link. Owners also see partial responses in results as "started, not finished" for drop-off analysis.
Answers with personal data aren't kept in localStorage, only the token. File fields upload while the respondent continues filling in other questions, directly to object storage with a pre-signed URL, as the file uploader guide describes; the answer stores the upload id, never the file itself.
The final submit carries an idempotency key, a random id created for each submit attempt and reused by its retries, so a double tap or a retry after a timeout creates exactly one response. The sequence below shows an upload, then the path where the server finds an error the client missed, for example a file that failed a virus scan, and the fixed resubmit.
422 maps back to a field and the error summary.Check your understanding · Exercise 9
Typing in a 150-field form feels slow on mid-range phones. Which changes help? Select all that apply.
Responses, results and CSV export
A response is the respondent's answers plus the facts needed to read them later: which form, which version, when, and whether it's complete. Owners look at responses in two ways: a results table in the app, with filters and paging, and a CSV export they open in Excel or Google Sheets, or sync to other tools every night. Both must work across every version the form has ever had.
{
"id": "rsp_01J9ZK4",
"formId": "frm_x81Lk",
"versionId": "ver_7",
"status": "complete",
"startedAt": "2026-03-04T09:12:44Z",
"submittedAt": "2026-03-04T09:19:02Z",
"answers": {
"f_7Kq2mX": "Ada Lovelace",
"f_9pLm1Q": "ada@example.com",
"f_diet": ["o_3", "o_5"],
"f_cv": { "uploadId": "upl_44c", "name": "cv.pdf", "size": 182044 }
}
}
Choice answers store option ids, not labels, for the same reason fields have ids: an owner who fixes "Vegeterian" to "Vegetarian" shouldn't split one option into two in the results. Labels are looked up from the response's own version when displaying, and from the newest version when exporting, with a fallback to the old label if the option was removed.
The results table
A popular form has 50,000 responses, so the results table pages on the server with a cursor, 50 rows at a time, and filters ("Attending is Yes", "submitted this week") are sent as the same JSON Logic conditions the builder uses, translated to database queries on the server. Columns are field ids; the header shows the newest label. The data table guide covers the table component itself (virtualized rows, sticky headers, column resizing); the form-specific part is choosing the columns, which is the same problem as the CSV.
Stable CSV columns across versions
Owners build spreadsheets on top of exports: formulas that point at column F, pivot tables, nightly imports. If version 5 inserts a question at position 2 and the export shifts every column right by one, every one of those breaks. The naive export, "columns in the order of the current form", breaks them on every publish.
Decision How should export columns be ordered across versions?
What we pick: We default to first-seen order and let owners save an export layout on top. Stability is what integrations need, and it's the default that can't break anything; an owner who wants form order can choose it once, knowing the trade-off. Headers use the field key (full_name) for machines, with an option for labels.
Each field type's core decides its own cells through csvHeaders and toCsv. A short text field is one cell, and an address field five. A checkbox field is one cell with the chosen labels joined by a semicolon, or, as an export option, one column per option with 1 or 0, which is what spreadsheet users want for counting. A file field exports a signed download link that expires after 7 days.
Exports above about 5,000 rows run as a background job: the client calls POST /forms/{id}/exports, polls the job (or gets a notification), and downloads a file from object storage. The server streams rows from the database into the CSV so memory stays flat.
One security rule applies to every cell: values that start with =, +, -, @, a tab, a carriage return or a line feed are prefixed with a single quote, because spreadsheet apps treat them as formulas. Without this, a respondent who types =HYPERLINK(...) into a public form plants a formula in the owner's spreadsheet. OWASP calls this CSV injection, and warns that Excel can drop the quote when a file is saved and reopened, so escaping lowers the risk rather than removing it.
Check your understanding · Exercise 10
Version 5 of a form adds "Pronouns" as the second question and removes "Fax number". With first-seen column order, what does the next export look like? Select all that apply.
Architecture, data model and API
Now we can put the pieces together. The system has two frontends with very different budgets, and a few shared packages between them and the server. The builder is a heavy, long-lived app for owners (drag library, rich editors, history, autosave); the renderer is a small, short-lived page for respondents. They share the field registry (its pure core and its views) and the logic and validation engine, and nothing else.
| Part | Lives in | Responsibility |
|---|---|---|
| Palette, Canvas, Properties panel | Builder | The three columns. Dispatch commands; never edit the draft directly. |
| Draft store and history | Builder | Applies commands with Immer, keeps undo and redo stacks, merges typing into one step. |
| Analyzer | Builder | Rebuilds the dependency graph after each command; reports cycles, dangling references, unreachable pages, accessibility lint. |
| Autosave | Builder | The save state machine: debounce, PUT with revision, retry with backoff, conflict handling, IndexedDB backup. |
| Preview bridge | Builder | Sends the draft to the preview iframe with postMessage; receives test results back. |
| Field core | Shared package | Per type: defaults, props spec, validate, serialize, CSV cells, sample values. No React, no DOM. |
| Logic engine | Shared package | JSON Logic evaluation, visibility in topological order, validateResponse. |
| Field views | Shared UI package | Per type: palette entry and Render component, one lazy chunk each. |
| Form runtime | Renderer | Answer store with per-field subscriptions, page navigation, partial saves, uploads, idempotent submit. |
| Forms, versions, responses, exports API | Server | Stores drafts and immutable versions, validates every submission with the shared core, runs export jobs. |
Why these boundaries? The shared core exists because the one bug we can't afford is the browser and the server disagreeing about a response, so the code that decides validity is literally the same code. The builder and renderer are separate bundles because their budgets differ several times over: the builder can afford around 400 KB of compressed JavaScript for an owner who stays an hour, while the renderer aims for about 60 KB for a respondent who stays two minutes.
The preview is an iframe running the renderer bundle because that is the only way the preview is guaranteed to be what respondents get. And the analyzer is a separate module from the store so it can move into a Web Worker if forms ever grow to thousands of fields; at 150 fields it runs in under a millisecond on the main thread, so we don't pay for the worker yet.
How data moves end to end
Follow one form from edit to export. The owner's commands change the draft in memory; autosave sends the whole draft to the API, which stores it with a new revision. On Publish, the API validates the draft with the shared core and the analyzer's checks, freezes it as an immutable version, and prerenders the first page to HTML that the CDN caches under the version id.
A respondent opens the link, gets that HTML with the definition inlined, and hydrates. Their answers stay in the answer store until a partial save or the final submit, which the API validates with the same core before writing. Results and exports read responses and merge versions by field id.
Check your understanding · Exercise 11
Why is the field core a separate package with no React in it? Select all that apply.
Where state lives
The definition types are in the definition chapter and the response shape in the responses chapter. What's left is where each piece of client state lives, and the API between the two apps and the server. Putting each piece in the right place prevents a whole class of bugs: selection in the draft store would make undo restore selections as edits; answers in localStorage would leak personal data on shared computers.
| State | Where it lives | Why there |
|---|---|---|
| Draft definition | Builder store in memory, mirrored to IndexedDB, saved to the server | Edited constantly; must survive a crash; the server copy is the shared truth. |
| Undo and redo stacks | Builder memory only | Per tab and per session; not worth persisting. Cleared on reload with a notice. |
| Selection, open panel, zoom | Component state; selected field id also in the URL hash | UI state, not form content. The hash lets an owner share "look at this field". |
| Analyzer problems | Derived from the draft, memoised | Never stored, so it can't go stale. |
| Published version | Server, CDN-cached HTML with the definition inlined | Immutable, so cache forever by version id. |
| Respondent answers | Renderer's answer store; partial saves on the server | Personal data stays off the device; only the resume token goes in localStorage. |
| Responses list, form list | Server cache (a query library like TanStack Query) | Server state with paging and refetching, not app state. |
Builder API
GET /forms/frm_x81Lk/draft
200 ETag: "rev-41"
{ "definition": {...}, "fields": {...}, "revision": 41 }
PUT /forms/frm_x81Lk/draft
If-Match: "rev-41"
{ "definition": {...}, "fields": {...} }
200 ETag: "rev-42" | 412 { "error": "stale_revision", "current": 43, "by": "Priya" }
POST /forms/frm_x81Lk/publish
{ "basedOnRevision": 42, "acknowledge": ["breaking:f_exp"] }
201 { "versionId": "ver_8", "url": "https://forms.example.com/f/frm_x81Lk" }
422 { "error": "invalid_definition", "problems": [
{ "code": "logic_cycle", "path": ["f_a", "f_b", "f_a"] } ] }
Two details are worth saying out loud in an interview. Publish requires the client to acknowledge each breaking change by id, so an owner can't publish one because a stale tab skipped the warning. And the server runs the same analyzer checks as the builder and refuses an invalid definition with the same problem codes, because a client is just one way to call the API.
Respondent API
PUT /f/frm_x81Lk/responses/rsp_01J9ZK4 (partial save, on page change)
{ "versionId": "ver_7", "answers": {...}, "page": "p_2" }
200 { "resumeToken": "rt_...", "savedAt": "2026-03-04T09:15:10Z" }
POST /f/frm_x81Lk/responses/rsp_01J9ZK4/submit
Idempotency-Key: 3f6c2a9e-...
{ "versionId": "ver_7", "answers": {...}, "challengeToken": "..." }
201 { "status": "complete", "confirmation": "Thanks, Ada!" }
409 { "error": "already_submitted" } (a new key; the same key replays the 201)
422 { "error": "invalid", "issues": [
{ "fieldId": "f_cv", "code": "upload_rejected", "message": "..." } ] }
The renderer as a component
Big customers embed forms in their own apps, and our builder uses the renderer in preview. So the renderer is also a component with a small, stable API. It takes a definition, a mode, and callbacks; it doesn't know about our URLs or our backend unless told. The component API design lesson covers why the surface stays this small.
type FormRendererProps = {
definition: FormDefinition;
mode: "live" | "preview" | "test";
registry?: FieldRegistry; // defaults to built-in types
initialAnswers?: Answers; // resume or test fill
onPartialSave?: (a: Answers, page: string) => Promise<void>;
onSubmit: (a: Answers) => Promise<SubmitResult>;
onEvent?: (e: FormEvent) => void; // page_view, field_error, submit
locale?: string;
};
<FormRenderer definition={version} mode="live"
onSubmit={(answers) => api.submit(formId, answers)} />
Check your understanding · Exercise 12
Why does PUT /draft send If-Match with the revision? Select all that apply.
Two editors at once, and keeping public forms safe
Two editors on one form
HR teams build forms together: one person writes the questions, another adds logic, a manager fixes wording before publish. They rarely edit at the same second, but they often have the form open at the same time, and a stale tab from yesterday is the most common source of lost work. With the revision check from chapter 5 we already detect the problem; the question is what to do about it.
Decision How should the builder handle two people editing the same form?
What we pick: We ship optimistic concurrency with presence and replay. It turns the dangerous case (a stale tab) into a visible, mostly automatic merge, at a fraction of the cost of real-time editing. Because every edit is already a command, moving to real-time later means sending those commands over a socket, not redesigning the builder. The conflict resolution lesson compares these strategies in general.
Replay works well here because form commands target ids. "Update label of f_9pLm1Q" applies cleanly whether or not a teammate moved that field or added others. It conflicts only when the target is gone (a teammate deleted the field) or when both changed the same prop of the same field, where we show both values and let the owner pick.
Owner text is untrusted input
Labels, help text, option names, the welcome screen and the thank-you message are written by owners and shown to strangers. Owners want bold, italics and links in them. Anyone can sign up as an owner, so this text is untrusted: if we ever put it into the page as HTML, an attacker publishes a form whose label contains a script, and every respondent runs it. Respondent answers are untrusted too, and they're shown to owners in the results table, which is a more valuable target because owners are logged in.
- Store rich text as structure, not HTML. Labels are a tiny Markdown subset (bold, italic, links) parsed into a tree of nodes and rendered as React elements. There is no
dangerouslySetInnerHTMLanywhere in the renderer. - Allow-list link targets. Links may use
https:,http:andmailto:only, which blocksjavascript:URLs, and open withrel="noopener noreferrer". - Sanitize HTML that arrives anyway. Imports from other tools may contain HTML. The server converts it to our structure, dropping everything else; if HTML must be kept, it goes through DOMPurify with a strict allow-list.
- Answer piping is text. "Thanks, {name}!" inserts the respondent's answer as a text node, never as markup.
- Separate origins. Public forms live on a different origin from the builder (
forms.example.comversusapp.example.com), with a strict Content Security Policy and nounsafe-eval. A bug in a form can't read the owner's session.
The XSS lesson covers the general defenses. What's specific here is the direction of trust: a form product has two untrusted audiences who see each other's text, so both directions need the same care.
Bots, spam and abuse
A public form is an unauthenticated write endpoint, and bots find them within days. Layers, from cheapest to most annoying: a honeypot, an extra text field moved off-screen with CSS, removed from the tab order and labelled "leave this empty" in case a screen reader reaches it, which naive bots fill in; a timing check, since a human rarely completes a 10-question form in under 3 seconds; and rate limits per IP and per form, for example 10 submissions per minute per IP, returning 429 Too Many Requests with a Retry-After header.
The last layer is an invisible challenge such as Cloudflare Turnstile or reCAPTCHA v3, whose token the server checks with the provider before storing anything. It has costs: a third party sees every respondent, and it can misjudge real people on privacy-focused browsers. A Turnstile token lasts 5 minutes and works once, so the renderer requests it at submit time, not on page load. Visible puzzles block people who can't solve them, so we keep them as a rare fallback, and for votes and contests owners can instead require sign-in or one response per verified email.
The last abuse is aimed at respondents: phishing forms that ask for bank passwords. That's why Google Forms prints "Never submit passwords through Google Forms" at the bottom of every form. We do the same, scan new forms for password-like fields and known brand names, and add a Report abuse link to every public form.
Check your understanding · Exercise 13
An owner writes a label containing <img src=x onerror=alert(1)>. Which defenses stop it from running in respondents' browsers? Select all that apply.
Running it in the interview
A form builder question is wide: an interviewer can push on drag-and-drop, schemas, logic, versioning, rendering or security. You can't cover all of it in depth in 60 minutes, so the skill is to name everything quickly, anchor the design on the definition, then go deep where the interviewer leans. Here is a plan that works.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 6 | Scope: who builds, who answers, form size, logic needs, live edits, volume. | Say: "Two apps share one definition. Can forms change while live?" The answer decides versioning. |
| 6 to 14 | The definition: pages, sections, a flat field map, ids, rules, conditions. | Write the JSON for two fields with ids and a showIf. Explain stable ids with the rename example. |
| 14 to 20 | The field registry and the shared core. | Draw builder, renderer and server all pointing at one registry. Say "drift is impossible by construction". |
| 20 to 28 | The builder: three columns, commands, undo, autosave, keyboard twins. | Wireframe, then the save state machine. |
| 28 to 38 | Logic and validation: JSON Logic, dependency graph, cycles, hidden values. | Draw a 4-node graph with one cycle and one dangling edge. |
| 38 to 48 | Publishing and versions, responses and CSV. | Draft to version timeline; the change classification table; first-seen columns. |
| 48 to 56 | Renderer performance, accessibility, security. | Per-field subscriptions, error summary, sanitized labels, bot layers. |
| 56 to 60 | Wrap up and trade-offs. | What you'd build first, and what you deferred (real-time co-editing, migrations). |
Draw the definition first, before any boxes. Every later answer refers to it: the registry interprets its type, logic reads its ids, versions freeze it, responses key into it, CSV columns follow it. An interviewer who sees you derive five features from one data shape will trust the rest of the design.
Follow-up questions to expect
Think first
How would you add a repeating group, like "add another child" with name and age for each?
Add a group field type whose props hold child field ids, with minItems and maxItems. Its value is an array of objects keyed by child field id: [{ f_name: "Mia", f_age: 6 }].
Conditions inside a repeat refer to siblings in the same item, so var paths gain an item scope. Validation runs per item and reports issues as f_kids[1].f_age. CSV export either flattens to numbered columns up to the maximum or exports the group as a second CSV linked by response id. Each choice has costs worth naming.
Think first
A customer's audit form has 1,500 fields. What breaks first, and what do you change?
The builder canvas: 1,500 mounted field components with drag measurement is slow. Collapse pages so only the open page's fields mount, and virtualize the canvas list (fine in the builder, unlike the public form). The analyzer may cross a few milliseconds per edit, so move it to a Web Worker and debounce it by one frame.
Full-draft autosave grows to about 1 MB of JSON. Switch to sending commands or JSON patches for drafts that size. The public renderer is fine as long as the owner splits pages, which the builder should nudge them to do.
Think first
Owners want calculated fields, like a quote total from quantities and prices. How do you add that safely?
A calculated field type whose value is a JSON Logic expression using arithmetic operators (+, *, if). It joins the dependency graph like any condition, so cycles (a total that depends on itself) are caught at edit time.
The server recomputes calculated values from the submitted answers and ignores what the client sent: a total is never trusted from the browser. Number formatting uses Intl.NumberFormat with the form's locale.
Check your understanding · Exercise 14
You have 5 minutes left and haven't discussed security. What is most worth saying? Select all that apply.
Summary
- The form definition is the center of the design: one UI-first JSON tree of pages, sections and a flat map of fields, with JSON Schema generated from it for integrations rather than used as the source.
- Every field, option, section and page has a random, immutable id. Answers, rules, filters and export columns point at ids, so renames and reorders never break data.
- One field registry, split into a pure core (validate, serialize, CSV) shared with the server and lazy-loaded React views, makes drift between builder, preview, live form and server impossible by construction.
- Every builder edit is a command through one store, which gives undo with merged typing, debounced full-draft autosave guarded by a revision, and replay after a conflict.
- Validation and logic are data: fixed rule kinds plus JSON Logic conditions built with menus. Never run owner-written JavaScript, and run owner regexes with a linear-time engine on the server.
- A dependency graph between fields catches cycles, dangling references and unreachable pages at edit time, and evaluates visibility in order. Hidden fields have no value: they are skipped by validation and stripped on the server.
- Published versions are immutable, responses record their version, type changes on answered fields create new field ids, and exports use first-seen column order so spreadsheets never break.
- The public renderer is a separate, small, server-rendered bundle with per-field subscriptions, accessible markup owned by the renderer frame, idempotent submit, and defenses against XSS in both directions and bots.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- JsonLogic The rule format used here for cross-field validation and show/hide conditions; see its operations page for the full list.
- JSON Schema specification What JSON Schema describes (data) and what it doesn't (layout, order, visibility).
- JSON Forms: UI schema The data schema plus UI schema approach, with controls that point into the data schema by scope.
- react-jsonschema-form documentation Another JSON Schema plus uiSchema renderer, useful to compare with a UI-first definition.
- Immer: patches How
produceWithPatchesreturns the patches and inverse patches behind undo and replay. - WCAG 2.2 Understanding 2.5.7: Dragging Movements Why every drag in the builder needs a single pointer alternative.
- GOV.UK Design System: Error summary A well-researched pattern for showing form errors that generated forms can copy.
- OWASP: CSV Injection Why exported cells starting with formula characters must be escaped.
- OWASP: Regular expression Denial of Service How owner-written patterns can hang a backtracking regex engine.
- W3C WAI: Grouping controls Fieldsets and legends for radio and checkbox groups, as the renderer generates them.
- Cloudflare Turnstile: Validate the token Why the server must verify challenge tokens, which last 5 minutes and work once.
- RE2 syntax The linear-time regex engine's syntax, including the backreferences and lookarounds it doesn't support.
- MDN: Window.postMessage() The channel between the builder and its preview iframe, including origin checks.
Concepts behind it
- Config-driven UIDescribing screens as JSON that names known components, rendered through an allowlisted registry, so layouts change without an app release: safe only if configs are validated, never executed, and older clients get fallbacks.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Conflict resolution strategiesWhat to do when two people change the same data: from last write wins per field, to version checks with
If-Match, to three-way merges and merge screens, chosen separately for each kind of data. - Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.