Kin Form
Build your field components once. Reuse them everywhere.
A framework-agnostic form state library for TypeScript.
The payoff
Forms read like composition, not wiring.
<form onSubmit={form.handleSubmit}>
<TextField api={form.field("email")} label="Email" />
<AddressField api={form.field("shipping")} />
<AddressField api={form.field("billing")} />
<ItemsField api={form.field("items")} />
<SubmitButton api={form}>Place order</SubmitButton>
</form>;Each component receives a resolved FieldApi, not a path or
form context. Define the UI and behavior once, then mount it anywhere its value
type fits. Kin Form keeps that component independently subscribed, so a change
only updates the part of the form that depends on it.
Why it exists
Reusable field components become awkward when a library treats the form as the only stateful object and fields as proxies into it. Nested objects, arrays, and shared validation then need their own special mechanisms.
Kin Form treats a form as a tree where every node (leaf, group, or the form itself) is the same thing, with its own state, configuration, and subscribers. That is why one component pattern works at every level.
Nothing forces one shape on a given value. Same { email, address: { line1, line2 } }, three valid trees:
Leaf. Any path in the value shape can be treated as a single leaf field. Here, address is.
Flat. Every scalar is its own field, addressed by its full path.
Grouped. address becomes an intermediate node, with line1/line2 registered underneath it.
What it does differently
One state machine, not two
A nested group and a leaf field are the same class, not a special case bolted onto it.
Type-safe paths
field("items.0.code") type-checks against your value type, so a typo'd path is a compile error.
No special-case array API
Push, insert, move, swap, and remove live on the same class every field already has, not a separate useFieldArray hook.
Declarative cross-field rules
List dependents on a field to re-validate siblings, instead of wiring a manual subscription.
Selective re-rendering
A change propagates only to the nodes it affects, so each subscriber re-renders only when the field, or selected state, it's watching actually changed.
Composable fields
Your reusable TextField, AddressField, and SubmitButton each take a FieldApi, so they work the same way whether bound to a leaf, a subtree, or the whole form.
Is Kin Form a fit?
Use it when forms become reusable UI
- You maintain field components across forms or apps
- Your forms have nested groups, dynamic arrays, or multiple steps
- You need stable array item identity and narrowly scoped re-renders
- You want typed field paths without a separate array API
- You need sync or async validation, scoped per field or subtree
- Field state must survive UI unmounts and remounts, such as rows in a virtual list
Skip it when the simple thing is enough
- The form is a small, one-off contact or login form
- Component-local state is already simpler
- Your team has a form-library standard that is working well and no pain worth migrating for
How it compares
| Features | Kin Form | React Hook Form | Formik | TanStack Form |
|---|---|---|---|---|
| Zero dependencies | ✅ | ✅ | ❌ | ⚠️ |
| Framework-agnostic core | ✅ | ❌ | ❌ | ✅ |
| Type-safe nested field paths | ✅ | ⚠️ | ❌ | ✅ |
| Standard Schema support | ⚠️ | ⚠️ | ❌ | ✅ |
| Same primitive for field, group, array, and form | ✅ | ❌ | ❌ | ❌ |
| Localized subscription | ✅ | ❌ | ❌ | ❌ |
| Selective re-rendering | ✅ | ⚠️ | ⚠️ | ✅ |
| Built-in async-validation debounce | ✅ | ❌ | ❌ | ✅ |
| Declarative cross-field revalidation | ✅ | ⚠️ | ❌ | ✅ |
| Field state survives list virtualization | ✅ | ⚠️ | ✅ | ✅ |
✅ full support · ⚠️ partial or conditional · ❌ not supported
Bundle size (React usage, gzip)
Flat field update burst (800×)
Full comparison, including where Kin Form isn't the right fit: see the details →
See it for yourself
01A login form
import { useForm, Watch } from "@kintools/form-react";
import { required } from "@kintools/form-validators";
function LoginForm() {
const form = useForm({
initialValue: { email: "" },
onSubmit: (form) => login(form.value),
});
return (
<form onSubmit={form.handleSubmit}>
{/* Watch is great for one-off UI or prototyping. */}
{/* Only re-render when the email field changes. */}
<Watch api={form.field("email", { validators: required("Required") })}>
{(field) => (
<label>
Email
<input
value={field.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.touched && field.error && <span>{field.error}</span>}
</label>
)}
</Watch>
{/* Only re-render when `form.submitting` flips. */}
<Watch api={form} select={(f) => f.submitting}>
{(_form, submitting) => (
<button type="submit" disabled={submitting}>Log in</button>
)}
</Watch>
</form>
);
}02Reusable TextField
import type { ReactNode } from "react";
import { type FieldApi, useWatch } from "@kintools/form-react";
export type TextFieldProps<TParentValue> = {
api: FieldApi<string, TParentValue>;
label: string;
type?: string;
};
export function TextField<TParentValue>(
{ api, label, type = "text" }: TextFieldProps<TParentValue>,
): ReactNode {
// Re-renders when the api's state changes.
const field = useWatch(api);
return (
<label>
{label}
<input
type={type}
value={field.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.touched && field.invalid && (
// Per-node validation and schema validation can co-exist.
<span>{field.error ?? field.schemaError}</span>
)}
</label>
);
}03Reusable SubmitButton
import type { ReactNode } from "react";
import { type FormApi, useWatch } from "@kintools/form-react";
export type SubmitButtonProps<TValue> = {
api: FormApi<TValue>; // Subclass of FieldApi.
children: ReactNode;
};
export function SubmitButton<TValue>(
{ api, children }: SubmitButtonProps<TValue>,
): ReactNode {
// Re-render only when submitting flips.
const submitting = useWatch(api, (f) => f.submitting);
return (
<button type="submit" disabled={submitting}>
{children}
</button>
);
}04Form with reusable components
import { useForm } from "@kintools/form-react";
import { required } from "@kintools/form-validators";
import { TextField } from "./TextField.tsx";
import { SubmitButton } from "./SubmitButton.tsx";
function LoginForm() {
const form = useForm({
initialValue: { email: "" },
onSubmit: (form) => login(form.value),
});
return (
<form onSubmit={form.handleSubmit}>
<TextField
api={form.field("email", { validators: required("Required") })}
label="Email"
/>
<SubmitButton api={form}>Log in</SubmitButton>
</form>
);
}