Type-Safe React Forms
It starts with a schema. It always starts with a schema.
// Define the rule once
const password = string([minLength(8)])
I define my validation rule: "Password must be at least 8 characters." Simple enough. Then I switch context to my component.
<input type="password" />
And I pause. I look at the schema. I look at the input. And I sigh.
<input type="password" minLength={8} />
I have to type it again. I have to manually tell the browser what I just told my validator. And right there, the drift begins. Two weeks later, security comes in. "Passwords need to be 10 characters now." I update the schema. minLength(10). I push the code. And then I get a bug report. "The error message says 10 characters, but the browser let me submit 8."
I forgot the input. Of course I forgot the input. Why wouldn't I? It's a completely separate file, a completely separate line of code, disconnected from the source of truth. Why are we still doing this?
I look at react-hook-form. It's great. It's powerful. But it feels... manual. register("password", { minLength: 8 }). I'm still defining the rules in the UI layer. I'm still shifting gears manually. I wanted something else. I wanted an Orchestrator.
The Idea
What if the schema wasn't just a gatekeeper? What if it was the blueprint? I didn't want to write validation logic and UI logic. I wanted to write the definition of the data, and have the UI just... happen.
- If I say
string([email()]), the input should know it'stype="email". - If I say
number(), the input should betype="number". - If I say
minLength(8), the input should haveminLength={8}.
I wanted to define the rules once, in Valibot, and have them ripple through the entire application.
The Solution: The Schema as the Orchestrator
So I built it. A form library where the schema drives the UI. It looks like this:
import * as v from "valibot";
import {
Form,
useFormFields,
type SubmitHandler
} from "werkbank/component/form";
// 1. Define the Single Source of Truth
const LoginSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
function LoginForm() {
// 2. Let the Orchestrator do the work
// The schema acts as the conductor, determining props for every field.
const formFields = useFormFields(LoginSchema);
// 3. Typed Submit Handler
// 'payload' is inferred as { email: string; password: string }
const handleSubmit: SubmitHandler<typeof LoginSchema> = (payload) => {
console.log("Login with:", payload.email);
};
return (
<Form schema={LoginSchema} onSubmit={handleSubmit}>
<label htmlFor={formFields.email.id}>Email</label>
{/* 4. Spread the props. That's it. */}
{/* This input now has type="email", required, and id */}
<input {...formFields.email} />
<label htmlFor={formFields.password.id}>Password</label>
{/* This input now has minLength={8}, required, and id */}
<input {...formFields.password} type="password" />
<button type="submit">Login</button>
</Form>
);
}
Do you see what happened there?
formFields.emailisn't justname="email". It'stype="email". It'srequired.formFields.passwordhasminLength={8}.
If I change the schema to minLength(10), the input updates. Automatically. No drift. No "double maintenance."
Why Not Just Use the DOM?
I hear the web purists. "Just use FormData! The DOM is the ultimate source of truth!" And they have a point. The DOM is excellent at holding state. But the DOM is terrible at types. To the DOM, everything is a string.
- "123" is a string.
- "true" is a string.
- "2023-01-01" is a string.
If I rely solely on the DOM, I'm back to parsing strings manually in my submit handler. parseInt(data.get('age')). data.get('isAdmin') === 'on'. This is where "Schema-First" shines. The schema bridges the gap. It tells the DOM how to behave (attributes), but it also tells the code how to interpret the result (parsing).
When werkbank hands you the data in onSubmit, it's not a bag of strings. It's numbers. It's booleans. It's Dates. It's real, typed data. The schema handles the transformation layer so you don't have to.
Performance: The Uncontrolled Advantage
"But does this re-render my whole app on every keystroke?" No. This is an uncontrolled form library. We are not binding value and onChange to React state for every character you type. The useFormFields hook uses useMemo to compute the attributes once (memoized based on the schema). The returned object is stable. When you type in the input, React stays out of the way. The browser handles the update. We only touch React state when:
- You submit the form.
- A validation error occurs (and we need to show UI).
This means you can have a form with 500 inputs, and typing in the 500th one feels just as fast as typing in the first. Zero lag. Zero unnecessary re-renders.
It Goes Deeper: Enums and Automatic UI
But why stop at simple attributes? What about the annoying stuff? Selects. Radio groups. Defining a dropdown in React is usually a chore. You have to map over options, manage keys, handle values. But if I have an Enum in my schema, I already know what the options are.
const MonsterEnum = {
Kraken: "K",
Sasquatch: "S",
Mothman: "M",
// ...
};
const schema = object({
monster: v.enum(MonsterEnum),
});
The library sees this. It knows it's an Enum. So useFormFields gives me everything I need to render a radio group or a select, without me typing the options again.
const fields = useFormFields(schema);
// It gives me the options array automatically!
<select {...fields.monster.select}>
{fields.monster.options.map(opt => (
<option key={opt.name} value={opt.value}>{opt.name}</option>
))}
</select>
When you submit the form, werkbank just reads the hidden input. It doesn't care about your fancy UI. It just cares about the data. This avoids the fragile dance of syncing React state back to the DOM manually. We let the DOM hold the state, even for custom components.
What about Dynamic Forms?
"This is cool for static forms," you say. "But I have a list of users that I can add and remove. I need useFieldArray." Dynamic forms are where many libraries fall apart. They force you into complex state management just to add a row. Because we rely on the DOM structure, handling arrays is surprisingly simple. You just render the inputs. If your schema expects an array of strings, you just render inputs with the same name.
// Schema: emails: array(string([email()]))
// DOM:
<input name="emails" type="email" />
<input name="emails" type="email" />
When you submit, werkbank sees multiple inputs with the name "emails", collects them into an array, and validates them against the array schema. Since the DOM order is the array order, reordering items is just reordering the rendered components.
You don't need a complex append or remove API. You just render React components. If you want to add a row? Just update your local state to render another <input>. The form library doesn't care how the inputs got there, only that they exist when you hit submit.
Bring Your Own Components
"But I have my own component library!" I hear you. You don't want to rewrite your FancyInput. You just want it to validate. That's where useInputValidation comes in. It's the low-level hook that powers everything else. It connects any component to the Form Context.
import { useInputValidation } from "werkbank/hook/useInputValidation";
function MyFancyInput({ name, ...props }) {
// Hooks into the parent <Form>, finds the schema for 'name',
// and gives you validation handlers.
const { onChange, onBlur, validate } = useInputValidation({ name });
return (
<div className="fancy-wrapper">
<input
name={name}
onChange={onChange}
onBlur={onBlur}
{...props}
/>
</div>
);
}
Now MyFancyInput participates in the form lifecycle. It validates on blur. It validates on submit. It reports errors. And you didn't have to write a single setState. And because we spread ...props last, you can still override anything manually if you have a weird edge case.
Closer to the Platform (But Not Too Close)
The best part? This isn't re-inventing validation. It's using the platform. We extract these attributes so we can leverage the browser's built-in Constraint Validation API. When you click submit, we don't run a heavy JS validation suite immediately. We let the browser check the type="email". We let the browser check the minLength. We get the red outlines and the tooltips for free. We also get the correct mobile keyboards (email, numeric) for free, just by using the right standard attributes.
"But wait," I hear you say. "Native validation UI is ugly and inconsistent." You're right. The default browser bubbles are terrible. That's why we use the API (reportValidity, setCustomValidity) for the logic, but you can still render your own UI. The library exposes onSchemaIssues and onValidationError so you can suppress the default bubbles and show your own beautiful, accessible error messages.
We use the browser as the engine, not necessarily the paint.
Accessibility is Not Optional
One thing that often gets lost in "magic" form libraries is accessibility. When an error happens, does the screen reader know? Because useFormFields generates unique IDs for every field, we can automatically link inputs to their error messages.
// The hook provides the IDs you need for ARIA
<input
{...formFields.email}
aria-describedby={`${formFields.email.id}-error`}
aria-invalid={!!errors.email}
/>
<span id={`${formFields.email.id}-error`}>{errors.email}</span>
We also handle focus management. When validation fails on submit, we automatically focus the first invalid field, ensuring keyboard users aren't left stranded. It feels native. It feels fast. It feels like we're working with the browser, not fighting it.
Internationalization (i18n)
"What about translations? My users don't all speak English." Because we're just using Valibot, we can use any i18n solution you like. You just pass the translated string (or a function) to the schema.
import { t } from "./i18n";
const schema = object({
password: pipe(
string(),
minLength(8, () => t("errors.passwordTooShort"))
),
});
The schema is just code. It runs at runtime. So your error messages are always up to date with the user's locale.
Why Valibot?
"Why not Zod?" you ask. "Zod is the standard." I love Zod. But we're already using Valibot for our Schema-First Router. It's tiny. It's modular. And most importantly, for a library that might end up in a client-side bundle, every kilobyte counts.
Valibot's modular design means you only bundle what you use. For many forms, the validation logic adds less than 1kb to your bundle. While we built this implementation for Valibot to get that tight integration, the pattern—Schema as Source of Truth—works with Zod, ArkType, or any other schema library. We just chose the lightest one.
Related Work
We didn't build this in a vacuum. We looked at the landscape and learned from the best.
- Formik: The pioneer. It taught us how to handle form state in React.
- react-hook-form: The gold standard. We love it. But it's imperative. You
registerfields. We wanted to be declarative: youdefineschemas. - remix-validated-form: Very similar philosophy! But tied to Remix. We wanted something that works in any React app (SPA or SSR).
- auto-form / uniforms: These libraries generate the entire UI for you. We don't. We generate the props. You still own the render. You still write the JSX. We just fill in the blanks.
Getting Started
TODO: Link to Getting Started Guide
Conclusion
I deleted so much code. I deleted manual minLength props. I deleted manual pattern regexes that were copied from StackOverflow. I deleted type="number" declarations. I just write the schema. And the form follows.
The Orchestrator has done its job. It joins the Router in our suite of schema-first tools. A world where the "Type" isn't just a static check—it's the engine that drives the application.
It’s not for everyone. If you love writing boilerplate, you’ll hate this. But if you want to trust your forms again? Stop syncing state. Start defining truth.