Kin Form
Build your field components once. Reuse them everywhere.
A framework-agnostic form state library for TypeScript.
# React projects (includes core).
npm add @kintools/form-react
# Lit projects (includes core).
npm add @kintools/form-lit
# Optional validators.
npm add @kintools/form-validators
# Optional: teach your coding agent Kin Form.
npx skills add kintools-dev/formWrite a field once
A field component takes a FieldApi, not a path or a form context. It
subscribes to that one field, so typing in it re-renders only it.
Build reusable field components →
import type { ReactNode } from "react";
import { type FieldApi, useWatch } from "@kintools/form-react";
export type TextFieldProps<TParentValue> = {
api: FieldApi<string, TParentValue>;
label: string;
};
export function TextField<TParentValue>(
{ api, label }: TextFieldProps<TParentValue>,
): ReactNode {
// Re-renders only when this field changes.
const field = useWatch(api);
return (
<label>
{label}
<input
value={field.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.touched && field.invalid && <span>{field.error}</span>}
</label>
);
}Compose a form
form.field(name, options) resolves a FieldApi. The name is checked against
the form's value type, and validators attach where the field is used.
SubmitButton takes the form itself, since a form is a FieldApi too. It's
built the same way as TextField above.
import { useForm } from "@kintools/form-react";
import { email, required } from "@kintools/form-validators";
import { TextField } from "./TextField.tsx";
import { SubmitButton } from "./SubmitButton.tsx";
function ContactForm() {
const form = useForm({
initialValue: { name: "", email: "" },
onSubmit: (form) => send(form.value),
});
return (
<form onSubmit={form.handleSubmit}>
<TextField
api={form.field("name", { validators: required("Required") })}
label="Name"
/>
<TextField
api={form.field("email", {
validators: [required("Required"), email("Invalid email")],
})}
label="Email"
/>
<SubmitButton api={form}>Send</SubmitButton>
</form>
);
}Reuse a group
A group is a field whose value is an object. AddressField resolves its own
children from the FieldApi it receives, so it mounts under billing,
shipping, or any path whose value is an Address.
import type { ReactNode } from "react";
import { type FieldApi, useForm } from "@kintools/form-react";
import { TextField } from "./TextField.tsx";
import { SubmitButton } from "./SubmitButton.tsx";
type Address = { line1: string; city: string; zip: string };
type Order = { billing: Address; shipping: Address };
function AddressField<TParentValue>(
{ api }: { api: FieldApi<Address, TParentValue> },
): ReactNode {
return (
<fieldset>
<TextField api={api.field("line1")} label="Address" />
<TextField api={api.field("city")} label="City" />
<TextField api={api.field("zip")} label="ZIP code" />
</fieldset>
);
}
const emptyAddress: Address = { line1: "", city: "", zip: "" };
function OrderForm() {
const form = useForm<Order>({
initialValue: { billing: emptyAddress, shipping: emptyAddress },
onSubmit: (form) => placeOrder(form.value),
});
return (
<form onSubmit={form.handleSubmit}>
<AddressField api={form.field("billing")} />
<AddressField api={form.field("shipping")} />
<SubmitButton api={form}>Place order</SubmitButton>
</form>
);
}Dynamic arrays
An array is a field too. A MembersField for one array type adds, moves, and
removes items with pushItem, moveItem, and removeItem on its api. Each
item keeps its own state as the list reorders, and each field's id is a stable
React/Lit key that follows it, no item-level id in the data needed.
import { type FieldApi, useForm, useWatch } from "@kintools/form-react";
import { TextField } from "./TextField.tsx";
import { SubmitButton } from "./SubmitButton.tsx";
type Member = { name: string; email: string };
type Team = { members: Member[] };
function MembersField({ api }: { api: FieldApi<Member[], Team> }) {
const members = useWatch(api, (f) => f.value);
return (
<fieldset>
{members.map((_member, i) => (
<div key={api.field(`${i}.name`).id}>
<TextField api={api.field(`${i}.name`)} label="Name" />
<TextField api={api.field(`${i}.email`)} label="Email" />
<button disabled={i === 0} onClick={() => api.moveItem("", i, i - 1)}>
Move up
</button>
<button onClick={() => api.removeItem("", i)}>Remove</button>
</div>
))}
<button onClick={() => api.pushItem("", { name: "", email: "" })}>
Add member
</button>
</fieldset>
);
}
function TeamForm() {
const form = useForm<Team>({
initialValue: { members: [] },
onSubmit: (form) => save(form.value),
});
return (
<form onSubmit={form.handleSubmit}>
<MembersField api={form.field("members")} />
<SubmitButton api={form}>Save</SubmitButton>
</form>
);
}Conditional fields
A field only exists while it's rendered. Create it inline where it's shown, and
it comes with its own validator. When the switch hides it, unregisterField
drops it, so a hidden field can't block submit.
Unregistering keeps the value unless you pass resetValue, so whether to clear
it stays your call.
Watch/watch here is different from the useWatch/WatchController inside a
field component: it's a render-prop that scopes a one-off re-render to a derived
condition, not a subscription owned by a component.
Conditional fields →
import { useForm, Watch } from "@kintools/form-react";
import { required } from "@kintools/form-validators";
import { SelectField } from "./SelectField.tsx";
import { TextField } from "./TextField.tsx";
import { SubmitButton } from "./SubmitButton.tsx";
type Signup = { type: "personal" | "business"; company: string };
function SignupForm() {
const form = useForm<Signup>({
initialValue: { type: "personal", company: "" },
onSubmit: (form) => save(form.value),
});
const typeField = form.field("type", {
onValueChanged: () => {
// Drops the company field and clears its value.
form.unregisterField("company", { resetValue: true });
},
});
return (
<form onSubmit={form.handleSubmit}>
<SelectField api={typeField} label="Account type">
<option value="personal">Personal</option>
<option value="business">Business</option>
</SelectField>
{/* Watch is a render-prop component for one-off UI. */}
{/* Only re-renders when the type changes. */}
<Watch api={typeField} select={(f) => f.value === "business"}>
{(_field, isBusiness) =>
isBusiness && (
<TextField
api={form.field("company", { validators: required() })}
label="Company"
/>
)}
</Watch>
<SubmitButton api={form}>Sign up</SubmitButton>
</form>
);
}Multiple steps
useMultistep (MultistepController in Lit) runs a wizard with one FieldApi
per step. next() validates the current step, marks its fields touched if it's
invalid, and advances only when it passes.
Multistep forms →
import { useForm, useMultistep } from "@kintools/form-react";
import { ShippingStep } from "./ShippingStep.tsx";
import { PaymentStep } from "./PaymentStep.tsx";
import { ReviewStep } from "./ReviewStep.tsx";
import { SubmitButton } from "./SubmitButton.tsx";
type Checkout = {
shipping: { address: string; city: string };
payment: { cardNumber: string };
};
function CheckoutWizard() {
const form = useForm<Checkout>({
initialValue: {
shipping: { address: "", city: "" },
payment: { cardNumber: "" },
},
onSubmit: (form) => placeOrder(form.value),
});
const { stepName, stepField, isFirstStep, isLastStep, back, next } =
useMultistep(form, ["shipping", "payment", null]);
return (
<form onSubmit={form.handleSubmit}>
{stepName === "shipping" && <ShippingStep api={stepField} />}
{stepName === "payment" && <PaymentStep api={stepField} />}
{stepName === null && <ReviewStep form={form} />}
{!isFirstStep && <button type="button" onClick={back}>Back</button>}
{!isLastStep && <button type="button" onClick={next}>Next</button>}
{isLastStep && <SubmitButton api={form}>Place order</SubmitButton>}
</form>
);
}Why it exists
Kin Form is an idea that has been refined over and over after years building four admin portals (which are mostly forms) across three stacks with hand-rolled form APIs and React Hook Form. Read the full story →
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, conditional fields, 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 model 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 →