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.

Build reusable field components →

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:

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, 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 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)

5.0 KB
Kin Form
13.0 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 →

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>
  );
}