React + TypeScript form component library built on React Hook Form and Zod.
npm install react-formkit
# peer dependencies
npm install react react-dom react-hook-form @hookform/resolvers zod
# also required: the package root imports the multiselect and phone controls
npm install react-select react-phone-input-2react-select and react-phone-input-2 are not optional. Everything is exported from the
package root, and that barrel imports MultiSelectInput and PhoneInput at the top level, so
importing anything at all from react-formkit resolves both - including a form that uses
neither control.
Form / BasicForm (FormProvider + zodResolver + ValidationSchemaContext)
└─ FormField (useController bridge + useIsFieldRequired)
└─ Field (HOC composition: withRequired -> withFieldMessage -> withLabel -> withControlProps)
└─ Control (type router)
└─ TextInput | NumericInput | SelectInput | SwitchInput | ...
| Type | Component | Description |
|---|---|---|
text |
TextInput | Standard text input |
numeric |
NumericInput | Number input with empty value handling |
textarea |
TextareaInput | Multi-line text |
select |
SelectInput | Dropdown with typed options and serialization |
checkbox |
CheckboxInput | Boolean checkbox |
checkbox-group |
CheckboxGroup | Multi-select checkboxes |
radio |
RadioGroup | Radio button group |
switch |
SwitchInput | Toggle switch |
password |
PasswordInput | Password with show/hide toggle |
email |
EmailInput | Email input |
url |
UrlInput | URL input |
date / time / datetime |
DateInput / TimeInput / DateTimeInput | Date and time pickers |
multiselect |
MultiSelectInput | Multi-value select (react-select) |
autocomplete |
AutocompleteInput | Text input with datalist suggestions |
multi-autocomplete |
MultiAutocompleteInput | Tag input (type + Enter) |
phone |
PhoneInput | Phone input with country code (react-phone-input-2) |
import { Form, FormField } from 'react-formkit'
import { z } from 'zod'
const schema = z.object({
name: z.string().min(2, 'Too short'),
email: z.string().email(),
role: z.string().min(1, 'Required'),
})
const MyForm = () => (
<Form buttonLabel="Submit" onSubmit={console.log} validationSchema={schema}>
<FormField name="name" type="text" label="Name" />
<FormField name="email" type="email" label="Email" />
<FormField name="role" type="select" label="Role" options={roleOptions} />
</Form>
)BasicForm is the same pipeline without the built-in submit button - use it when you supply your own controls.
A submit has two sides and both are props on Form and BasicForm. While the handler is in flight, loadingOverlay blocks the form; once it has resolved, onSuccess fires and successContent takes the form's place:
const OrderForm = () => (
<Form
onSubmit={createOrder}
onSuccess={(order) => navigate(`/orders/${order.id}`)}
successContent={<p>Thanks - your order is on its way.</p>}
loadingOverlay
>
<FormField name="email" type="email" label="Email" />
</Form>
)onSuccess receives what onSubmit resolved to and the values that were submitted, so a handler that returns the created record hands it straight to whatever comes next. Neither side runs when the schema rejects the form or when the handler throws.
successContent replaces the form. Pass keepFormOnSuccess to render it alongside instead, which is what a form that can be submitted more than once wants - a confirmation rather than the next screen. It shows from the submit that succeeded until the next submit is in flight, and comes back when that one succeeds.
The overlay blocks the form for real: it paints above the form's own positioned content, the form stops receiving pointer events, and keyboard focus is moved into the overlay and held there until it closes, when it returns to the control that had it. The markup carries .fk-form-loading-overlay, and the success content .fk-form-success.
import { required, email, minLength, phone } from 'react-formkit' // common
import { positive, between, integer, percentage } from 'react-formkit' // numbers
import { latinOnly, cyrillicOnly, digitsOnly } from 'react-formkit' // charset
import { personName, companyName } from 'react-formkit' // presetsRequired fields are detected from the schema - a field with .describe('required') or min(1) shows the required indicator automatically via useIsFieldRequired. Cross-field rules go through useFormLevelValidators.
asyncCheck wraps a field schema in a debounced remote check - "is this username still taken?". The check resolves true when the value is acceptable, and mode decides when it runs:
import { Form, FormField, asyncCheck, required } from 'react-formkit'
// hoist the schema: the debounce lives in the closure the validator was built with,
// so a schema rebuilt each render restarts the window each render
const schema = z.object({
username: asyncCheck(required(), isUsernameFree, { delay: 300, message: 'That username is taken' }),
})
const SignupForm = () => (
<Form validationSchema={schema} mode="onChange" onSubmit={console.log}>
<FormField name="username" type="text" label="Username" />
</Form>
)The check is never asked about an empty value, clearing the field cancels a request already counting down, and a verdict about a value the user has moved on from cannot decide the value now in the field. A check that throws fails open, so a network blip leaves the field submittable.
mode is passed straight to React Hook Form (onChange, onBlur, onTouched, all, onSubmit) and applies to every rule on the form, not just the async ones.
useIsAsyncValidating reports whether one field is waiting on its remote check - true from the keystroke that arms the debounce window until the verdict lands, and one uninterrupted wait across a burst of keystrokes rather than one per key:
const UsernameField = () => {
const waiting = useIsAsyncValidating('username')
return (
<>
<FormField name="username" type="text" label="Username" />
{waiting && <Spinner />}
</>
)
}It works anywhere inside a Form or BasicForm, and is false for a field with no asyncCheck on it.
React Hook Form's own per-field isValidating answers a different question here. The resolver parses the whole schema, so RHF marks the field whose event started the parse rather than the fields actually waiting on a request, and on submit it marks every mounted field whether it has a remote check or not.
Describe a form as data and render it from a config array:
import { Form, useFormFromConfig, ConfigFields, FormConfig } from 'react-formkit'
// keep the config at module scope: it is fine for the array identity to change,
// but a rule edited in place will not reach the schema - see below
const config: FormConfig = [
{ name: 'fullName', type: 'text', label: 'Full name', validation: ['required', { rule: 'minLength', value: 2 }] },
{ name: 'email', type: 'email', label: 'Email', validation: ['required', { rule: 'email' }] },
{ name: 'age', type: 'numeric', label: 'Age', validation: [{ rule: 'min', value: 18 }] },
]
function SignupForm() {
const { defaults, fields, schema } = useFormFromConfig(config)
return (
<Form defaultValues={defaults} validationSchema={schema} onSubmit={console.log}>
<ConfigFields config={fields} />
</Form>
)
}useFormFromConfig derives the default values and a Zod schema from the config; ConfigFields renders the controls onto the native Field/Control stack.
The defaults and the schema are memoised on a signature built from the field names and types only, so an inline array literal does not rebuild them on every render. Hoisting the config no longer buys any speed. What it costs instead is a staleness rule worth knowing before you generate a config at runtime.
An edit that changes neither a name nor a type does not reach the schema or the defaults.
Tightening minLength from 2 to 8, swapping a message, adding a required, or changing a
defaultValue all leave the signature identical, so the memo hands back the schema it built the
first time and a value the config now rejects still submits. Changing a field's name or type,
or adding or removing a field, does refresh both.
The rest of the config is read fresh on every render, because fields is the array you passed
straight back: label, placeholder, options, disabled and showWhen are live and need no
signature change to take effect. It is only the two derived values that are cached.
So a config that changes shape at runtime is fine. A config whose rules change while its shape stays put is the case to avoid - give a field a new name, or key the component on the config version, so the signature moves with the rules.
Duplicate field names throw. A repeated name would last-win in both the defaults and the
schema, so it is rejected instead:
Error: useFormFromConfig: duplicate field name(s): email
Every repeated name is listed, comma-separated. This throws during render rather than warning, so a config assembled from more than one source is worth de-duplicating before it reaches the hook.
A field is rendered only while a sibling matches its showWhen condition (is, isNot, or a test predicate):
const config: FormConfig = [
{ name: 'hasAddress', type: 'checkbox', label: 'I have a mailing address' },
{ name: 'street', type: 'text', label: 'Street', showWhen: { field: 'hasAddress', is: true } },
]A config-driven conditional field is validated only while it is visible, and its value is dropped when it hides - so a required field inside a collapsed branch can neither block submit with an unreachable error nor smuggle a stale value into the payload.
The same thing outside a config, around any subtree:
<ConditionalField when="hasAddress" is={true} clear="street">
<FormField name="street" type="text" label="Street" />
</ConditionalField>clear is opt-in here and takes a field name or a list of them. Without it the branch keeps
its values while hidden, which is what you want when a branch is only collapsed for space.
Validation of a hand-written schema is yours to make conditional; the automatic half applies to
showWhen in a config.
FormFieldArray repeats its children once per item in an array-valued field. It is a render
prop, and the row it hands you carries the path prefix to build field names from:
<FormFieldArray name="contacts" empty={<p>No contacts yet</p>}>
{({ name, index }) => (
<FormField name={`${name}.email`} type="email" label={`Email ${index + 1}`} />
)}
</FormFieldArray>Compose the name from row.name rather than writing contacts.${index} yourself - that is the
one place a field array goes quietly wrong. Rows are keyed by react-hook-form's own row id, not
by the index, so a later reorder moves a row rather than retyping two of them.
Errors land on the row's own path, and the required indicator now reads the schema through that
path too, so a min(1) inside the element schema marks the field in every row.
Add, remove and reorder come with the row. Each row carries remove, moveUp and moveDown,
plus isFirst / isLast so a row knows which of the two to offer; the array as a whole gets an
actions slot with append and count:
<FormFieldArray
name="contacts"
actions={({ append }) => (
<button type="button" onClick={() => append({ email: '' })}>
Add contact
</button>
)}
>
{({ name, remove, moveUp, isFirst }) => (
<>
<FormField name={`${name}.email`} type="email" label="Email" />
<button type="button" onClick={remove}>
Remove
</button>
<button type="button" onClick={moveUp} disabled={isFirst}>
Up
</button>
</>
)}
</FormFieldArray>The buttons are yours; the component supplies only the behaviour. actions renders in the empty
state as well, because an add control that appears only once a row exists leaves an emptied array
with no way back. A move past either end is refused rather than passed on. append takes the
value a new row starts as - the component never sees the element schema, so it cannot invent one.
Array shapes are a hand-written-schema feature: a config cannot express a repeated field yet.
- The config's defaults and schema are memoised on field names and types, so a rule or a
defaultValueedited without renaming or retyping its field does not reach them. - Duplicate field names in a config throw rather than resolving to the last one.
requiredis not yet enforced across all field types, and non-required fields are not made optional.- No nested / grouped fields in a config;
FormFieldArrayis the hand-written-schema half only. - A conditional field is cleared by dropping it from the form, so a hidden branch is absent from the submitted values rather than present and empty.
- Async and cross-field rules are not part of the config schema (use
useFormLevelValidators). - The loading overlay is styled inline,
z-index: 10included, so.fk-form-loading-overlaycan add to it but cannot restyle what is set there.
Styling is driven by CSS custom properties. Override them in your own :root (or any scope):
:root {
--fk-focus-color: #2b6cb0;
--fk-border-color: #cbd5e0;
--fk-focus-ring: rgba(43, 108, 176, 0.25);
--fk-disabled-bg: #edf2f7;
--fk-radius: 6px;
}npm install
npm run dev # playground
npm test # vitest
npm run build # library buildActive development. Core form system, validation, cross-field rules, and the full control set are functional. Type declarations are not generated yet.