@fransek/form
v0.7.0
Published
Simple form management without sacrificing control.
Downloads
494
Readme
@fransek/form
Simple form management without sacrificing control.
Installation
npm install @fransek/formOverview
@fransek/form is a headless form library for React. It manages field state and validation without rendering any UI — you stay in full control of markup and styling.
The entire API is three components and three utilities:
| Export | Description |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------- |
| <Form> | Wraps a <form>, coordinates submit-time validation across all fields |
| <Field> | Headless field component. Manages validation lifecycle via a render prop |
| <FormState> | Reactively exposes aggregate form state (isValid/isTouched/isDirty/isValidating/isSubmitting/canSubmit) via a render prop |
| createFieldState(initialValue) | Creates the initial FieldState for a field |
| validate(state, validators, mode?) | Runs synchronous validators outside of a <Field> |
| validateAsync(state, validators, mode?) | Runs async validators outside of a <Field> |
Quick Start
import { createFieldState, Field, Form } from "@fransek/form";
import { useState } from "react";
function required(value: string) {
if (!value) return "This field is required";
}
export function MyForm() {
const [name, setName] = useState(createFieldState(""));
return (
<Form
onSubmit={async ({ event, validate, commit }) => {
event.preventDefault();
if (await validate()) {
console.log("submitted:", name.value);
}
commit();
}}
>
<Field
state={name}
onChange={setName}
validation={{ onChange: required }}
>
{({ value, handleChange, handleBlur, ref, errorMessage }) => (
<div>
<input
value={value}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
ref={ref}
/>
{errorMessage && <p>{errorMessage}</p>}
</div>
)}
</Field>
<button type="submit">Submit</button>
</Form>
);
}Field State
Every field is backed by a FieldState<T> object. Create it with createFieldState:
const state = createFieldState(""); // FieldState<string>
const state = createFieldState<string | null>(null); // FieldState<string | null>
const state = createFieldState<string[]>([]); // FieldState<string[]>FieldState<T> has the following shape:
interface FieldState<T> {
value: T;
errorMessage: React.ReactNode; // undefined when valid
isTouched: boolean; // true after the field has been blurred
isDirty: boolean; // true after the value has changed
isValid: boolean;
isValidating: boolean; // true while an async validator is running
}Validation
Validators
A synchronous validator returns an error message (any truthy React.ReactNode) or a falsy value when valid:
const required = (value: string) => (!value ? "Required" : undefined);An async validator returns a Promise of the same:
const checkAvailable = async (value: string) => {
const taken = await api.check(value);
return taken ? "Already taken" : undefined;
};Validation triggers
Pass validators to <Field> via the validation prop. Each key maps to a different trigger:
<Field
state={state}
onChange={setState}
validation={{
onChange: required, // sync, runs on every change
onChangeAsync: checkAvailable, // async, debounced (default 500 ms)
onBlur: required, // sync, runs on blur
onBlurAsync: checkAvailable, // async, runs on blur
onSubmit: required, // sync, runs on form submit
onSubmitAsync: checkAvailable, // async, runs on form submit
}}
/>Validation mode
By default, errors are shown only after the field has been both touched and changed ("touchedAndDirty"). Override this on <Form> or per <Field>:
| Mode | When errors appear |
| ------------------- | ------------------------------------------- |
| "touchedAndDirty" | After blur and a value change (default) |
| "touchedOrDirty" | After blur or a value change |
| "touched" | After blur only |
| "dirty" | After a value change only |
// Set a default for all fields
<Form validationMode="touched">
// Override for a specific field
<Field validationMode="dirty" ...>Validation dependencies
Use dependency arrays when a validator depends on values outside the field itself. Dependency arrays are explicit: they control when a field should revalidate because related external state changed. When one of those external values changes, the field is revalidated using the validators whose dependency arrays changed.
<Field
state={repeatPassword}
onChange={setRepeatPassword}
validation={{
onChange: (value) =>
value !== password.value ? "Passwords do not match" : undefined,
onChangeDependencies: [password.value],
}}
>
{(props) => (
<input
value={props.value}
onChange={(e) => props.handleChange(e.target.value)}
onBlur={props.handleBlur}
ref={props.ref}
/>
)}
</Field>In this example, changing password reruns the repeat-password check even when
the repeat-password field value itself stays the same.
Form
<Form> is a thin wrapper around <form> that provides context to child fields and coordinates submit-time validation.
<Form
onSubmit={async ({ event, validate, commit }) => {
event.preventDefault();
const isValid = await validate();
if (isValid) { /* ... */ }
commit({ focusFirstError: true, scrollOffset: 100 });
}}
validationMode="touchedAndDirty" // default for all fields
debounceMs={500} // default async debounce for all fields
skipAsyncValidationOnSubmit // optional default for all fields
>validate evaluates every registered field using its onChange, onBlur, and
onSubmit validators and returns whether they pass. By default it also runs
onChangeAsync and onBlurAsync; set skipAsyncValidationOnSubmit on
<Form> (default for all fields) or on a specific <Field> to skip those two
async hooks during submit validation. onSubmitAsync still runs. Provide the
matching *Dependencies array whenever a validator also depends on external
state and should be revalidated when that state changes. commit then applies
pending validation state updates, runs onCommit
validators, and optionally focuses the first invalid field.
onSubmit may be async. When the handler returns a promise, the form's
isSubmitting aggregate state (see Aggregate Form State)
is true while that promise is pending and resets to false when it settles —
even if it rejects.
Aggregate Form State
<FormState> reactively derives the combined state of every field in the
surrounding <Form> and passes it to a render prop. It re-renders only when the
aggregate changes — typing that doesn't flip a flag does not cause extra renders.
import { Field, Form, FormState } from "@fransek/form";
<Form
onSubmit={async ({ event, validate, commit }) => {
event.preventDefault();
if (await validate()) {
await save(); // isSubmitting is true while this runs
}
commit();
}}
>
<Field state={name} onChange={setName} validation={{ onChange: required }}>
{(props) => <input {/* ... */} />}
</Field>
<FormState>
{({ canSubmit, isSubmitting }) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? "Submitting…" : "Submit"}
</button>
)}
</FormState>
</Form>;The render prop receives a FormAggregateState:
interface FormAggregateState {
isValid: boolean; // every field is valid (true when there are no fields)
isTouched: boolean; // at least one field has been touched
isDirty: boolean; // at least one field value has changed
isValidating: boolean; // at least one field is running async validation
isSubmitting: boolean; // an async onSubmit handler is in flight
canSubmit: boolean; // isValid && !isSubmitting && !isValidating
}canSubmit intentionally does not require the form to be dirty, so a
pristine but valid form (e.g. one with only optional fields) is submittable.
Compose canSubmit && isDirty yourself if you want to block submitting an
unchanged form.
Render Props
The children function of <Field> receives a FieldRenderProps<T> object:
interface FieldRenderProps<T> extends FieldState<T> {
handleChange: (value: T) => void; // call on input change
handleBlur: () => void; // call on input blur
ref: (el: HTMLElement | null) => void; // attach to the root input element
}Always attach ref to enable focusFirstError on submit.
Validate a FieldState Manually
For cross-field validation inside a form, prefer *Dependencies on the
relevant <Field>. That keeps revalidation attached to the field that owns the
error state:
<Field
state={repeatPassword}
onChange={setRepeatPassword}
validation={{
onChange: (value) =>
value !== password.value ? "Passwords do not match" : undefined,
onChangeDependencies: [password.value],
}}
>Use validate or validateAsync when you need to validate a FieldState
manually outside a <Field> lifecycle — for example, in custom state
orchestration, reducers, or tests:
const nextCoupon = validate(form.coupon, [requiredCoupon, knownCoupon]);
const nextEmail = await validateAsync(form.email, [
requiredEmail,
checkEmailAvailability,
]);
setForm((prev) => ({
...prev,
coupon: nextCoupon,
email: nextEmail,
}));validate accepts a single validator or an array and stops at the first error.
validateAsync runs all validators in parallel and returns the first error in
validator-list order.
Dynamic Fields
Manage dynamic field lists by storing each field's FieldState in an array:
const [items, setItems] = useState<{ id: number; state: FieldState<string> }[]>(
[],
);
// Add a field
setItems((prev) => [...prev, { id: nextId++, state: createFieldState("") }]);
// Render
{
items.map((item, index) => (
<Field
key={item.id}
state={item.state}
onChange={(state) =>
setItems((prev) => {
const next = [...prev];
next[index].state = state;
return next;
})
}
validation={{ onChange: required }}
>
{(props) => (
<input
value={props.value}
onChange={(e) => props.handleChange(e.target.value)}
onBlur={props.handleBlur}
ref={props.ref}
/>
)}
</Field>
));
}