Why I Built Adaptive Forms

coding

Why I Built It

Every app I build ends up needing the same two views of a form.

Desktop gets a full form: every field visible, no ceremony. Mobile gets something stepped — smaller decision surfaces, one thing at a time. I like that split. I want it on every project, not just the ones where I happen to have time to build it well.

The problem was never the split itself. It was that I kept rebuilding the machinery underneath it: one set of values feeding two layouts, validation that has to mean the same thing in both places, accessibility state that has to stay honest no matter which view is rendering.

After enough repetition, I stopped wanting to rebuild that layer from scratch.

That is why I started adaptive-forms.

The goal was not to make another general-purpose form library. The goal was to make a small, opinionated layer for the exact pattern I keep wanting: one form definition, two interaction modes, shared validation rules, and a field registry that stays flexible enough for real product work.

What I Was Repeating

A lot of form libraries are good at the big picture.

They help with state, validation, and submission. Some are optimized for schema-driven workflows. Some are great at simple uncontrolled inputs. Some are good UI kits with form primitives attached.

But my problem kept being narrower than that.

I wanted one form definition to drive both a full desktop layout and a stepped mobile layout. I wanted the same validation truth in both modes. I wanted the same accessibility behavior in both modes. I wanted the same registry of field types so that I could add new app-specific inputs without rewriting the entire form system just to get the desktop/stepped split I already knew I wanted.

That is the part I was rebuilding over and over — not the app around it, just this one layer.

adaptive-forms exists to own that layer.

The library sits between the app and the form engine. React Hook Form still does the core form work. adaptive-forms supplies the shared structure around it: configuration, rendering, navigation, and visibility rules — so that "full form on desktop, stepped flow on mobile" is something I configure once instead of something I re-implement per project.

Why Not Just Use What Already Exists

There are already solid open source solutions in this space.

React Hook Form is excellent. Formik exists. Schema validators are mature. UI libraries cover inputs, dialogs, and layout. If all I wanted was a generic form with a few fields, I would not need another abstraction.

But I was not trying to solve a generic problem.

I was trying to solve the same specific pairing every time:

That is where a focused library becomes useful.

A narrow library is not automatically a bad library. In some cases it is the better one because it solves a real repeated problem instead of pretending to solve every form problem.

Why Hyper Focused Can Be a Strength

I think "hyper focused" can sound like a weakness when people first hear it. In practice, it often means the library has a clear boundary.

That is a good thing.

A focused library is easier to reason about because the contract is smaller. It is easier to test because there are fewer moving parts. It is easier to adopt because you do not need to buy into a giant framework just to get one useful behavior. And it is easier to keep honest because every feature has to justify its place.

That is the tradeoff I wanted.

I do not want adaptive-forms to become another giant form framework. I want it to stay small enough that I can trust it, extend it, and understand it when something goes wrong — and I want the desktop/stepped split specifically to stay boring and predictable everywhere I use it.

I am not optimizing for theoretical breadth. I am optimizing for repeatable utility around one pattern I actually want every time.

The Bug That Made the Boundary Clearer

The best example of why the library needed to be opinionated was a one-line bug in early form logic.

The rule was meant to be considerate: if a password field is empty and untouched, do not immediately show an error. That sounds reasonable. No one wants a form shouting at them before they have done anything.

The problem was that the rule was too broad.

Once the user tried to advance a step or submit, the same condition could still hide the error. The form remained blocked, but there was no visible explanation. Because the built-in PasswordField derived both its message and aria-invalid from that same filtered value, the accessibility state reported the wrong thing too — a required field could sit there invalid with aria-invalid="false".

The bug looked like this in spirit:

password: {
  hideErrorWhenEmpty: true,
  render: (props) => <PasswordField {...props} />,
}

That line was trying to be helpful. In practice, it collapsed two different ideas into one:

Those are not the same question — and worse, an exhaustive unit suite that compared the new helper against a verbatim port of the old logic proved they were "equivalent," because it faithfully reproduced the same bug it was supposed to catch.

The fix was not to make the condition cleverer. The fix was to make the contract clearer, and to add outcome-level integration tests instead of trusting oracle-equivalence:

password: {
  render: (props) => <PasswordField {...commonProps(props)} />,
}

Untouched fields can stay quiet. But once the user asks to advance or submit, validation truth needs to win. hideErrorWhenEmpty still exists — it is just no longer a default anyone opts into by accident.

That bug mattered because it showed the difference between presentation timing and validation state. It also showed why a focused library is useful: when the same logic is reused across multiple apps, a mistake in that layer becomes visible quickly and can be fixed once, instead of once per project.

What Adaptive Forms Actually Gives Me

At a practical level, the library gives me a few things I keep needing:

One form definition can power both the full desktop view and the stepped mobile view, off a single useBreakpoint hook and a single useForm instance.

A registry lets me add field types without rewriting the entire form system.

Validation stays centralized instead of leaking into every screen.

Accessibility behavior is part of the contract, not an afterthought.

Step navigation validates only the current step; final submit validates everything.

That combination is the point.

If I were only building one form for one screen, I would probably use plain React Hook Form and move on. But the desktop/stepped pairing is something I want by default, and I want the shared behavior behind it to be boring and reliable every time I reach for it.

That is what libraries are for.

Where It Fits

adaptive-forms is not trying to replace the tools underneath it.

It does not replace React Hook Form. It does not replace validation libraries. It does not replace UI components. It does not replace the rest of the app.

It adds a layer above those tools that encodes one specific pattern I keep wanting: a form that shows up differently depending on device, without ever having two sources of truth.

That is why I think it is a good library even though it is specific.

In fact, the specificity is the reason it works.

A broad tool often forces me to adapt my app to the library. A focused tool lets the library adapt to the actual shape of the problem. For my use case, that is the right tradeoff.

What I Learned

The biggest lesson was not about forms in general. It was about scope.

If I keep wanting the same interaction pattern across multiple projects, that pattern is probably real enough to deserve a library — even if the apps around it have nothing else in common. If I can define the boundary clearly, the library becomes a better fit than a pile of copied code.

That is what happened here.

I built adaptive-forms because I wanted the same desktop-form/stepped-form split, backed by one shared source of truth, in every app I build — without remaking it each time. I kept it narrow because the narrowness is what makes it useful. And I am comfortable saying that a hyper-focused library can still be a good library when it solves a repeated problem better than the broader alternatives.

That is the version I want to keep building.