A type-safe, easy-to-use form controller for React, where a keystroke stays in the field it was typed into.
Declare your form type once. Every field, validation, condition and submit is checked against it, and a keystroke renders the field it was typed into and nothing else.
import { createForm } from "formco-core";
type MyFormValues = { givenName: string; surname: string };
const { FormController, Input, Submit } = createForm<MyFormValues>();
<FormController onSubmit={(fields) => console.log(fields)}>
<Input
name="givenName"
validation={(value) => !value?.trim() && "Provide a given name"}
/>
<Input
name="surname"
validation={(value) => !value?.trim() && "Provide a surname"}
/>
<Submit>Submit</Submit>
</FormController>;name only accepts a key of MyFormValues, value inside a validation is the
type that key holds, and onSubmit receives { givenName, surname }, typed.
There is nothing else to set up: no resolver, no schema, no provider, no
register call, no useForm in a parent component.
Every component is also exported on its own and takes a controller prop, for
the forms which would rather pass one - that is the smaller import, and the
API below covers both.
Typing costs zero renders. Field values live in the controller, not in React state or context. Type four characters into a field and the render count of the form, its siblings and the submit button is still what it was on mount. A field renders again only when something about it changed - its validity, its message, whether it is disabled. That is not a claim, it is a test:
type("givenName", "J");
type("givenName", "Ja");
type("givenName", "James");
expect(renderCount(fieldComponentName, "givenName")).toBe(1);
expect(renderCount(fieldComponentName, "surname")).toBe(1);
expect(renderCount(formControllerComponentName)).toBe(1);One is the whole budget: the controller is built during the render, so mounting a form is a single pass, and a reset costs exactly one more.
A validation is a function. It takes the value and returns what is wrong with it - a string, an element, or nothing at all. No schema language to learn, no adapter package to install, no build step.
The types come from your form type. name only accepts a key of MyForm,
fields inside a validation is a Partial<MyForm>, and onSubmit hands you
the real thing. Rename a field and the compiler finds every use. Declare it once
with createForm and it reaches every field, condition and submit on its own -
no type argument, no controller prop, at any depth.
It scales down. The whole API is one component per element plus three composition helpers. A two-field form is fifteen lines; a registration form with asynchronous validation is still a single component.
npm install formco-coreReact 17, 18 and 19 are peer dependencies. Every CI run exercises the built
package against React 19 with StrictMode on and type-checks the published
.d.ts under @types/react 19 - see
packages/compat.
Share the form across a component tree
Put the form in its own module and the whole tree can use it, however deeply a field is nested:
// my-form.ts
export type MyFormValues = {
email: string;
newsletter: boolean;
};
export const MyForm = createForm<MyFormValues>();// Newsletter.tsx - a field, nowhere near the FormController
const { Input } = MyForm;
export const Newsletter = () => (
<Input label="Newsletter" name="newsletter" type="checkbox" />
);Reach the form from anywhere under it:
const controller = MyForm.useController(); // Controller<MyFormValues>
const email = MyForm.useFieldValue("email"); // string | undefined
const isValid = MyForm.useIsValid(); // booleanuseFieldValue subscribes to that one key, so a component reading a value does
not render when a sibling field is typed into. See it running under
One type, every field.
Call createForm at module scope - called during a render it mints new
component identities every pass and React remounts every field. Each call gets a
context of its own, so two forms on one page never see each other's controller.
Every component still takes a controller prop, which wins over the context;
the components exported on their own take it as before and are the smaller
import.
Validate while typing, or on blur, or only on submit
// on the form, for every field
<FormController validateOnChange validateOnBlur>
// or on one field, overriding the form
<Input name="surname" validateOnChange={false} />Read another field
<Input
name="passwordConfirm"
type="password"
validation={(value, fields) =>
value !== fields.password && "Passwords do not match"
}
/>Reading fields.password is enough - the controller notices which fields a
validation touches and re-runs it when they change. Use
validationDependencies={["password"]} when the read is conditional and does
not happen on the first render.
Ask a server
<Input
name="username"
validation={(value) => ({
content: <Spinner />,
promise: () =>
fetch(`/username/${value}`)
.then((response) => response.json())
.then(({ isValid }) => ({
isValid,
content: isValid ? undefined : "This username is taken"
})),
wait: 500 // hold the request back until the typing pauses
})}
/>A newer keystroke discards the queued result, so you do not need to debounce anything yourself. A submit waits for a pending validation before it fires.
Required fields
<Input name="email" required requiredInvalidMessage="An email is required" />Show something only when it applies
<Condition showIf={(fields) => Number(fields.age) >= 18}>
<Input name="licenceNumber" />
</Condition>
<Condition ifFormValid>
<p>Everything checks out.</p>
</Condition>;Put a message somewhere else
<Input hideMessage name="email" />
<MessageFor name="email" />Share the same rules across fields
<Validation required validation={(value) => !value && "This one is needed"}>
<Input name="street" />
<Input name="city" />
</Validation>Bring your own components
<Input
Component={TextField} // any component taking value/onChange/onBlur
MessageComponent={FormHelperText}
name="email"
/>
<Submit ButtonComponent={Button} disableIfNotValid>
Submit
</Submit>Disable, hide, reset, submit
<Input disableIf={(fields) => !fields.country} name="city" />
<Input hideIf={(fields) => !fields.other} name="otherText" />
// from any component under the form
const controller = MyForm.useController();
<button onClick={() => controller.resetForm()}>Reset</button>
<button onClick={() => controller.submit()}>Submit from anywhere</button>| Member | Description |
|---|---|
FormController |
provides the form; children are nodes or a render prop |
Condition Input MessageFor Select SelectOption Submit Textarea Validation |
the same components, bound to T |
useController() |
the controller, at any depth |
useFieldValue(name) |
one value, subscribed to that key alone |
useIsValid() |
form validity, rendering only when it flips |
The bound components take one type argument rather than four, and it is inferred
from name - <Input<"email">> only when inference needs help.
The root. Its children are ordinary nodes, or a function receiving the
Controller.
| Prop | Description |
|---|---|
onSubmit(fields, controller) |
called when a submit passes validation |
initialValues |
starting values; resetForm restores them |
initialValidation |
validate on the first render instead of on submit |
validateOnChange / validateOnBlur |
when to validate; both overridable per field |
validation / disableIf / hideIf |
per-key maps instead of setting them on each field |
requiredInvalidMessage / requiredValidMessage |
default messages for required fields |
options.trimValues |
trim string values before they reach onSubmit |
options.scrollToError |
scroll to and focus the first invalid field on submit |
options.validationTimeout |
hold the validation back while the user is still typing |
| Prop | Description |
|---|---|
name |
required; a key of your form type |
controller |
required on the standalone components, optional on the bound ones |
type |
any text-like input type, plus checkbox and radio |
validation validationDependencies |
see above |
disableIf(fields) / hideIf(fields) |
disable or unmount the field based on the current values |
required requiredComponent |
mark as required, optionally replacing the * |
hideMessage hideRequiredStar |
suppress the built-in message or star |
label |
rendered before text inputs and after checkboxes and radios |
Component MessageComponent |
render your own field and message components |
onFormChange(name, props) |
called whenever any field in the form changes |
Select takes SelectOption children, which accept their own disableIf and
hideIf. Pressing Enter inside a field submits the form.
disableIfNotValid disables the button while the form is invalid,
disabledByDefault starts it disabled, ButtonComponent swaps in your own
button. It disables itself while a submit waits on a pending asynchronous
validation.
Condition renders its children when ifFormValid and/or showIf(fields)
hold;
dynamicContent re-renders the content on every form change. MessageFor puts
a field's validation message somewhere else in the tree. Validation shares
validation, disableIf, hideIf and the required options with the fields
beneath it - written on its own it needs its form type spelled out, as
<Validation<MyForm>>, while the one createForm hands out already knows it.
| Member | Description |
|---|---|
fields |
current values of every visible field |
isValid isSubmitted |
form state |
getFieldValue(key) |
a single value |
submit() |
submit programmatically |
resetForm() |
restore initialValues |
disableFields(bool) |
disable every field and button at once |
validate(force?) |
validate every visible, enabled field |
The class names the fields set, so you can style them: field-invalid,
field-valid, field-required, field-required-star, field-message,
field-message-invalid, field-message-valid.
One entry point, ESM + CJS, sideEffects: false, one module per export - the
bundler drops what you do not import. Measured with webpack 5 in production
mode with React external:
| What you import | Minified | Gzipped |
|---|---|---|
Controller |
20.3 KiB | 4.2 KiB |
FormController Input Submit |
31.5 KiB | 7.3 KiB |
every field, submit and CN |
33.0 KiB | 7.7 KiB |
| everything, including the composition helpers | 35.2 KiB | 8.2 KiB |
One entry point is deliberate. Named ESM exports plus sideEffects: false shake
at the granularity of a single component, which is finer than any subset entry
point could ever be drawn - import three names and you pay for three names.
Every example runs on https://martintichovsky.github.io/formco-core, published from
main on every push. Or clone the repository and run:
npm install
npm run build
npm run run:demoAround sixty examples on http://localhost:9009, each of them a fixture of the
test suite, with the source of the one on screen shown beside it - including a
Material UI section, where every control is a Material UI one driven through
the Component prop. See
packages/examples
for screenshots and how to add one.
MIT
