Skip to content

Repository files navigation

formco-core

NPM Build Status Coverage Status

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.

Why

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.

Install

npm install formco-core

React 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.

Examples

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(); // boolean

useFieldValue 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>

API

createForm<T>()

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.

FormController<T>

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

Input / Textarea / Select

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.

Submit

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, MessageFor, 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.

Controller

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

CN

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.

Size

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.

Demo

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:demo

Around 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.

License

MIT

Releases

Packages

Used by

Contributors

Languages