npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

formco-core

v1.0.0

Published

Lightweight, re-render conscious form controls for React

Readme

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