Why I Stopped Hand-Building Forms and Started Compiling Them
Jul 2026 ยท 9 min read
Every product team I've worked on eventually hits the same wall: forms. Not one form โ dozens of them, each slightly different from the last, each hand-built by whoever picked up the ticket that sprint. A signup form. An address form. A KYC form with eleven conditional fields. A settings form that's really four forms wearing a trench coat. Every one of them gets its own bespoke validation logic, its own layout code, its own accessibility afterthoughts, and its own set of bugs that only show up in review.
At MassMutual, this wasn't a hypothetical problem โ it was showing up as a specific, measurable cost: 2 months of sprint-planned form work, repeated every time a new workflow needed a form. That number is what pushed me to stop treating forms as something you write and start treating them as something you compile โ from a schema, the same way a build tool compiles source into output.
The core bet: forms as data, not code
The premise behind ReactFill is simple to state and harder to actually commit to: a form is a JSON array, and a form is a React component are the same idea, so let the JSON generate the component instead of a developer writing it by hand.
const schema: FormFieldSchema[] = [
{ name: "email", label: "Email", type: "email", required: true },
{
name: "employer",
label: "Employer",
type: "select",
dependsOn: "employmentStatus",
getOptions: (values) => fetchEmployers(values.employmentStatus),
},
];
<DynamicForm schema={schema} onSubmit={handleSubmit} />That's the whole pitch. No JSX for the employer field, no manual wiring of a controlled input to a validation rule to an error message. The schema is the form. Everything downstream โ layout, validation, conditional visibility, accessibility attributes โ is a function of that one data structure.
This only works if the underlying form-state engine is trustworthy, so ReactFill doesn't reinvent that part. It's built directly on React Hook Form, which gives it uncontrolled inputs and minimal re-renders for free. The interesting engineering problem wasn't "how do we track form state" โ that was already solved well. It was "how do we describe every real-world form shape as data, without the schema itself becoming an unreadable pile of conditionals."
The field registry: 18 types, and a way out of all of them
ReactFill ships 18 field types out of the box โ text, select, multiselect, file, slider, rating, repeatable field arrays, grouped fieldsets, multi-field rows, and more. The obvious naive implementation is a giant switch statement mapping typeto a component. That's also the worst possible implementation, for two reasons: it can't be tree-shaken, and it can't be extended without forking the library.
Instead, field types are resolved through a fieldRegistry โ a plain object mapping a type string to a component. The default export ships the full registry, but DynamicForm accepts a custom fieldRegistryprop. A consumer that only uses five field types can pass a registry containing only those five, and the bundler drops the rest. A consumer with a bespoke field type the library doesn't ship โ MassMutual's internal component library, for instance โ can register it under whatever type name they choose and use it exactly like a built-in.
This is the difference between a library and a framework. A framework tells you the field types that exist. A registry tells you the field types are a slot, and gives you a sane default filling that slot.
Conditional logic without "if hell" in the consumer
The second hard problem is conditional fields โ show this field only if that one is set, require this one only in certain states, disable this one until a dependency resolves. Every hand-built form I've seen solves this the same way: a wall of useEffect and watch() calls scattered through the component, each one a little more fragile than the last.
In ReactFill this logic lives in the schema as declarative condition groups โ AND/OR combinations across 6 comparison operators โ evaluated centrally by the form engine rather than by hand-wired effects in the consuming component:
{
name: "spouseIncome",
label: "Spouse Income",
type: "number",
visibleWhen: {
all: [{ field: "maritalStatus", operator: "equals", value: "married" }],
},
}The win isn't just less code. It's that the conditional logic for a given field lives next to that field's definition, instead of scattered across a component body in whatever order a developer happened to add it. When something's wrong with a form's behavior, you know exactly where to look.
Multi-step wizards, and why validation timing is the actual hard part
A form with a built-in multi-step wizard sounds like a UI feature. It's actually a validation-timing problem wearing a UI costume. The hard question isn't "how do we render a progress bar" โ it's "when a user clicks Next on step 2, which fields do we validate, and what happens to the validation state of step 1 if they go back and change something."
React Hook Form gives you validation modes (onSubmit, onBlur, onChange), but a wizard needs per-step validation gates layered on top of that โ you want step-2 fields to stay untouched by validation until the user actually reaches step 2, even though they're all registered in the same underlying form instance. Getting this right is what separates a wizard that feels solid from one that either yells at users about fields they haven't seen yet, or lets them barrel through required fields because the validation gate didn't actually block anything.
Accessibility as a default, not a checklist
Every field ReactFill renders gets aria-required, aria-invalid, and aria-describedby wired up automatically, with a required-field asterisk marked aria-hiddenso screen readers don't announce a bare asterisk as content. This is one of those things that's cheap to do when it's baked into the field implementation and expensive to retrofit once forty hand-built forms already exist without it โ which is exactly the position most teams are in when someone finally asks "wait, is this accessible?"
What actually happened when we adopted this internally
The number that mattered wasn't lines of code saved โ it was cycle time. Three sprints of planned form workflows landed in about a week once the schema pattern was in place, because the work shifted from "build a form" to "write a schema and review it," and schema review is faster than component review. Fewer places for logic to hide means fewer rounds of back-and-forth in code review, and a validation bug is a one-line schema fix instead of a component-level regression hunt.
The fork we run internally diverges from the open-source version in a few places โ mostly around wiring it into an existing internal component library rather than shipping its own default styles โ but the core schema engine is unchanged. That was the whole point of building it as a registry-based, framework-agnostic library instead of something tied to one design system: the parts that are genuinely hard (conditional logic, validation timing, accessibility defaults) don't need to be rebuilt per company. Only the fieldrendering does.
What I'd still change
The trickiest edge case in practice is chained async dependencies โ field C depends on field B's resolved options, which depend on field A's value, and all three can change out from under each other while a request is in flight. The current dependsOn / getOptionspattern handles the common two-level case cleanly, but a three-level chain with race conditions is still something I'd want a more principled answer for than "the last request to resolve wins." That's next on the list.
ReactFill is open source on npm and GitHub, if you want to see the schema engine itself.