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/form

Write 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:

Form with two fields: email, and address as a single leaf.formemailaddress

Leaf. Any path in the value shape can be treated as a single leaf field. Here, address is.

Form with three flat fields: email, address.line1, and address.line2, all direct children of the form.formemailaddress.line1address.line2

Flat. Every scalar is its own field, addressed by its full path.

Form with email as a leaf and address as a group, with line1 and line2 registered underneath it.formemailaddressline1line2

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

FeaturesKin FormReact Hook FormFormikTanStack 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)

5.1 KB
Kin Form
13.7 KB
React Hook Form
13.9 KB
Formik
18.5 KB
TanStack Form

Flat field update burst (800×)

1.4 ms
Kin Form
66.5 ms
React Hook Form
3.3 ms
Formik
564.2 ms
TanStack Form

Full comparison, including where Kin Form isn't the right fit: see the details →