Why Kin Form?
Most form libraries make the form object the sole owner of state: register a field and you get a proxy into that one store, not an object with its own value/error/validators. Nested objects, dynamic arrays, and cross-field rules end up routed through a second mechanism instead of being a plain field. It shows up differently in each library below, but the gap is the same.
React Hook Form's problems
- Arrays need a separate hook,
useFieldArray— no group node for a nested object at all. - Reusable group/array components need manual casts to stay type-safe — the compile-time path check doesn't survive a generic wrapper.
- No selective subscription — a component re-renders on any change to a field-state key it touches, not on whether the value it computes from those keys changed.
- Inefficient by design — dirty/subscriber bookkeeping runs across every registered field on every update, not just the one that changed, regardless of how many components actually re-render.
- Heavy — 13.0 KB gzip.
See vs React Hook Form for the full comparison.
Formik's problems
- No type safety —
nameis a plain string with no compile-time path check, and neither a field's value nor a group/array's items are typed; a typo'd path fails silently at runtime instead of at compile time. - Its Context re-renders every consumer on any change, by design.
- Heavy — 13.9 KB gzip.
TanStack Form's problems
- Validation is ceremony-heavy — named validator slots per event, and cross-field rules are awkward to wire up.
- Heaviest bundle - 18.5 KB.
- The slowest of the three.
Kin Form
Kin Form starts from a different premise: a form is a tree, and every node in that tree — leaf field, nested group, or the form itself — is the same kind of thing.
One state machine, one shape
Every node — leaf input, nested object/array, or the form itself — is the same class, FieldApi: value, error, touched, validating, dirty, validators (sync, async, and schema), plus a lazily-populated registry of its own child fields.
Whether an object/array-valued field is treated as one atomic leaf or decomposed into children is up to you, not the engine.
FormApi is just the FieldApi at the root (parent === null, name === ""), with submission and reset logic added on top.
That means the same mental model applies everywhere:
- Setting a node's
valuebubbles up into the parent's value. - Setting a node's
valuecascades down into every registered child. touched/invalid/validatingaggregate from children automatically — a node isinvalidif it or any registered child is.- Every node can be subscribed to independently — a node's own change never notifies unrelated siblings, and
react/'suseWatch/Watchadd selector-based diffing on top, so a component re-renders only when what it computes changes.
Nothing here is a separate array-field abstraction or a separate whole-form-state abstraction. It's the same properties, all the way down.
Type-safe paths, not string soup
const form = new FormApi({
initialValue: {
email: "",
address: {
line1: "",
},
items: [
{ id: 1 },
],
},
});
form.field("email").value; // string
form.field("address.line1").value; // string
form.field("items.0.id").value; // numberDeepKey<T> computes every dot-joined path into T (through objects and arrays alike) as a literal string union; DeepValue<T, Key> resolves the value type at that path. A typo'd path is a compile error, not a silent undefined at runtime — field(name, options) type-checks against your form's actual value type, no manual generics needed.
Validation that doesn't fight you
kin-form supports flexible validation strategies: sync or async, per-node or per-subtree.
validators— plain sync functions on any node (field, group, or form):(field) => result, run in order immediately, no debounce; first truthy result wins.asyncValidator— a separate, singular option alongsidevalidators, for a check that needs to hit a server. Debounced, and only fires once everyvalidatorsentry already passes.schemaValidator— one schema (zod, valibot, ...) validating a whole subtree's value in one pass, instead of a rule per field. Runs alongsidevalidators/asyncValidator, not instead of them.
Whichever combination is running:
- Coalesced — concurrent or redundant
validate()calls join a single in-flight run instead of stacking up duplicate work. - Stale-safe — if a newer run supersedes an older one, the older result is discarded when it resolves, so it can never clobber a fresher answer.
Cross-field rules are declarative, not manual subscriptions:
form.field("password", {
dependents: ["confirmPassword"],
validators: required("Password is required"),
});
form.field("confirmPassword", {
validators: (f) =>
f.value !== form.value.password ? "Passwords must match" : null,
});Whenever password changes, confirmPassword re-validates automatically — no manual wiring, no re-render-everything.
Stable array item identity
pushItem/insertItem/moveItem/swapItems/removeItem update the immutable value and re-key the field registry together, so a field's identity follows its item through a reorder, not whatever value now sits at its old index. Every field also carries a stable id, independent of name, that survives the same reorders — the right React key for a list of array items (key={field.id}, not key={index}).
Opt-in complexity
@kin-form/core has no UI framework dependency — it's just the state machine. @kin-form/react adds hooks and render-prop components on top. @kin-form/validators is a separate package on purpose: validator wording and edge cases churn far more than the engine does, so the two version independently. You pick up exactly the layers you use.
What's next
- Getting Started — install and build your first form
- Concepts — the tree model, shared state, and typed paths
- Basic — building
TextFieldfromWatch, the pattern the rest of these guides lean on - Per-node Validation and Schema Validation
- Nested Objects and Dynamic Arrays
- Linked Fields and Listeners — reacting to a value changing