vs TanStack Form

Of the libraries compared here, TanStack Form is the closest to Kin Form's mental model: a framework-agnostic core, controlled binding, type-safe field paths, and selective re-rendering through selectors. So the comparison is less about whether a given feature exists and more about how large the API surface is, how many distinct primitives you assemble to build a form, and where the type-safety and ergonomics diverge. Like the React Hook Form page, this one works through the same topics the guide covers, one at a time, against @tanstack/[email protected].

Field registration & binding model

Native input

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}>
      {/* Only re-renders when the email field changes. */}
      <Watch api={form.field("email", { validators: required("Required") })}>
        {(field) => (
          <>
            <input
              value={field.value}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {field.error && <span>{field.error}</span>}
          </>
        )}
      </Watch>
 
      <button type="submit">Log in</button>
    </form>
  );
}

What's different:

Kin FormTanStack Form
Binding modelControlled everywhere (value/handleChange)Controlled everywhere (field.state.value/field.handleChange)
Field primitive<Watch api={form.field(name, opts)}>: resolve a field, then watch it<form.Field name={name}>{(field) => ...}</form.Field>, one component bound to form
Submit wiringonSubmit={form.handleSubmit}, bind directlyHandler calls e.preventDefault(), then form.handleSubmit()
Reading the errorfield.error (a string | null)field.state.meta.errors (an array)

Both bind controlled, and the shapes are close. The main structural difference is that form.Field is a single primitive bound to the form, whereas Kin splits "resolve a field" (form.field(name, opts)) from "watch it" (<Watch> / useWatch). For a one-off field, form.Field inline is a touch less ceremony. Kin's split is what lets an already-resolved FieldApi be handed to a reusable component (see Form composition below), where every call site collapses to one line too.

This is the only section using a bare <input>. Everywhere else both sides bind to a controlled <TextInput> (or <CountrySelect>), since that is the case worth comparing.

Non-native input

import { useForm, Watch } from "@kintools/form-react";
import { required } from "@kintools/form-validators";
 
function ProfileForm() {
  const form = useForm({
    initialValue: { country: "" },
    onSubmit: (form) => save(form.value),
  });
 
  return (
    <form onSubmit={form.handleSubmit}>
      <Watch api={form.field("country", { validators: required("Required") })}>
        {(field) => (
          <>
            <CountrySelect value={field.value} onChange={field.handleChange} />
            {field.error && <span>{field.error}</span>}
          </>
        )}
      </Watch>
 
      <button type="submit">Save</button>
    </form>
  );
}

What's different:

Kin FormTanStack Form
Non-native inputsSame <Watch> as any other fieldSame <form.Field> as any other field

Both treat a custom-component field exactly like a native one, with no extra primitive. This is the Controller tax React Hook Form pays and neither of these does. Nested groups and arrays are covered under Form composition; there the two diverge, because a TanStack FieldGroupApi is a separate type from FieldApi, whereas a Kin group is just a FieldApi whose value is an object.

Per-node validation: when it runs, and debouncing

import { useForm, Watch } from "@kintools/form-react";
 
function SignupForm() {
  const form = useForm<{ username: string }>({
    initialValue: { username: "" },
  });
 
  return (
    <Watch
      api={form.field("username", {
        asyncValidator: async (field) =>
          (await checkUsernameTaken(field.value)) ? "Username taken" : null,
        validationDebounceMs: 300,
      })}
    >
      {(field) => (
        <TextInput value={field.value} onChange={field.handleChange} />
      )}
    </Watch>
  );
}

This is a place the two are close. Both ship a built-in async-validation debounce (validationDebounceMs on Kin, asyncDebounceMs on TanStack), so neither needs the hand-rolled lodash/debounce the React Hook Form page shows. Both also run async validation only after the synchronous rules for that field have passed, so an expensive check never fires for a value already known bad.

What's different:

Kin FormTanStack Form
Sync validationvalidators array, run in order on every value change, first truthy winsvalidators.onChange / .onBlur, one function per event hook
Async validationOne asyncValidator slot, runs after every sync validators entry passesvalidators.onChangeAsync / .onBlurAsync, separate per event hook
DebouncevalidationDebounceMs, applies to asyncValidator (and schemaValidator)asyncDebounceMs, overridable per hook via onChangeAsyncDebounceMs / onBlurAsyncDebounceMs
Config shapeOne validators array plus one asyncValidator plus one debounce numberA validators object keyed by event (onChange, onBlur, onChangeAsync, onBlurAsync, onSubmit, ...)

The functional coverage is the same. The difference is Kin's single validators array plus a separate async slot, versus TanStack's validators object where each timing (change, blur, submit; sync and async) is its own key. TanStack's model makes "validate only on blur" a one-key change. Kin runs sync validators on every change always, and leaves blur-only display to the render (field.touched).

Schema validation

This is a place TanStack Form is genuinely nicer.

import { useForm, Watch } from "@kintools/form-react";
import { required, toSchemaValidator } from "@kintools/form-validators";
import { z } from "zod";
 
const signupSchema = z.object({
  email: z.email(),
  password: z.string().min(8),
});
type Signup = z.infer<typeof signupSchema>;
 
function SignupForm() {
  const form = useForm<Signup>({
    initialValue: { email: "", password: "" },
    schemaValidator: toSchemaValidator(signupSchema),
  });
 
  return (
    <Watch api={form.field("email", { validators: required("Required") })}>
      {(field) => {
        // Both channels are live at once, not one overriding the other.
        const error = field.error ?? field.schemaError;
        return (
          <>
            <TextInput value={field.value} onChange={field.handleChange} />
            {error && <span>{error}</span>}
          </>
        );
      }}
    </Watch>
  );
}

TanStack Form has native Standard Schema support: a schema is a validator, passed straight to validators.onChange at the field or form level, with no @hookform/resolvers-style package and no toSchemaValidator(). Any Standard Schema library works (zod, valibot, arktype, effect). Kin needs the toSchemaValidator() adapter from @kintools/form-validators for the same thing.

What's different:

Kin FormTanStack Form
Adapter packagetoSchemaValidator() from @kintools/form-validatorsNone; a Standard Schema is a validator as-is
Where a schema attachesAny node (field, group, or form) via its own schemaValidator; the nearest one wins beneath itField-level validators.onChange, or form-level validators; a field's own entry overrides the form's for that field
Schema plus hand-written rulesCoexist: schemaError is separate from error, neither overwrites the other (field.error ?? field.schemaError)Merge into one field.state.meta.errors; a field's own validators replaces the form schema's result for that field
Standard Schema librarieszod, valibot, arktype, ... (any)zod, valibot, arktype, effect, ... (any)

Kin's counter is scope and separation, not ergonomics: a schema can sit on any node (not just the whole form), and its output lands in a field's own schemaError, kept apart from the error its own validators produce, so a field can carry both at once and decide how to combine them. On TanStack, a field-level validators entry overrides the form-level schema for that field rather than running alongside it.

Cross-field validation

Both are declarative here, unlike React Hook Form's manual trigger(). They declare the link from opposite ends of the relationship.

function SignupForm() {
  const form = useForm<Signup>({
    initialValue: { email: "", password: "", confirmPassword: "" },
  });
 
  return (
    <>
      <Watch api={form.field("password", { dependents: ["confirmPassword"] })}>
        {(field) => (
          <TextInput
            type="password"
            value={field.value}
            onChange={field.handleChange}
          />
        )}
      </Watch>
 
      <Watch
        api={form.field("confirmPassword", {
          validators: (field) =>
            field.value !== form.value.password ? "Passwords must match" : null,
        })}
      >
        {(field) => (
          <>
            <TextInput
              type="password"
              value={field.value}
              onChange={field.handleChange}
            />
            {field.error && <span>{field.error}</span>}
          </>
        )}
      </Watch>
    </>
  );
}

What's different:

Kin FormTanStack Form
Which field declares the linkThe source field (password) lists its dependentsThe dependent field (confirmPassword) lists onChangeListenTo
Reading the other valueform.value.password inside the validatorfieldApi.form.getFieldValue("password") inside the validator
Trigger granularitydependents re-runs the target's validators on any value changeonChangeListenTo / onBlurListenTo, separate arrays per event
Fan-outOne dependents array on the source covers every dependentOne onChangeListenTo array per dependent field

Same spirit, opposite ends. Kin puts the wiring on the field being watched, so adding a dependent is an edit to the source field's dependents. TanStack puts it on the field doing the watching, so the field that owns the rule also declares what re-triggers it. Both avoid a manual refire call.

Dirty tracking & reset

function ProfileForm() {
  const form = useForm({
    initialValue: { firstName: "", lastName: "" },
    onSubmit: (form) => save(form.value),
  });
 
  return (
    <form onSubmit={form.handleSubmit}>
      <Watch api={form.field("firstName")}>
        {(field) => (
          <>
            <TextInput value={field.value} onChange={field.handleChange} />
            {field.dirty && <span>Edited</span>}
          </>
        )}
      </Watch>
 
      <Watch api={form} select={(f) => f.dirty}>
        {(f, dirty) => (
          <button disabled={!dirty} onClick={() => f.reset()}>
            Discard changes
          </button>
        )}
      </Watch>
    </form>
  );
}

What's different:

Kin FormTanStack Form
Whole-form dirtyform.dirty: !deepEqual(value, baseline), flips back to false on revertform.state.isDirty: true once any field changed, stays true after a revert
"Differs from the default right now"field.dirty: same deepEqual-against-baseline model!field.state.meta.isDefaultValue: isDirty alone will not flip back
Per-field dirty subscriptionfield.dirty inside a Watch/useWatch scoped to that fieldfield.state.meta.isDirty / .isDefaultValue, from the field's slice of the store
Resetform.reset(value?), moves the baseline tooform.reset(values?, opts?)
Reset one fieldform.resetField(name, value?)form.resetField(name)

The models differ. Kin's dirty is a live deepEqual against the baseline, so typing a character and deleting it again leaves the field clean. TanStack's meta.isDirty is a "has ever been edited" flag that stays set after a revert by design; for Kin's semantics you read !meta.isDefaultValue instead. TanStack exposes both flags so you pick; Kin gives you the one behavior.

Submission handling

Both name a callback for "the form failed validation," separate from the success path. Only Kin also has one for "the submit function itself threw."

const form = useForm<Signup>({
  initialValue: { email: "", password: "" },
  onSubmit: async (form) => {
    await signUp(form.value);
  },
  onSubmitInvalid: (form) => {
    form.touched = true; // Reveal errors on never-blurred fields.
  },
  onSubmitError: (form, error) => {
    toast.error("Sign up failed"); // Called automatically, no wrapper needed.
  },
});
 
// handleSubmit itself calls preventDefault when given an event.
<form onSubmit={form.handleSubmit}>;

What's different:

Kin FormTanStack Form
Validation failedonSubmitInvalidonSubmitInvalid
onSubmit itself throwsonSubmitError, invoked automatically; the form stays submittableNo dedicated callback; the error lands in form state and canSubmit goes false, so wrap onSubmit in try/catch yourself
Binding to <form>onSubmit={form.handleSubmit}, event optional, so the same call works from a React Native onPressHandler calls e.preventDefault(), then form.handleSubmit()
Submit-in-progress stateform.submittingform.state.isSubmitting, form.state.canSubmit

onSubmitInvalid is parity, and unlike React Hook Form's positional second argument, both give it a name. The gap is the same one React Hook Form has: no callback for "the submit function threw," so a failed request inside onSubmit is yours to catch. TanStack additionally flips canSubmit to false after an uncaught submit error, so a bare re-click will not retry until an input changes. Kin's onSubmitError fires automatically and leaves the form submittable.

Async initial values

Neither accepts an async defaultValues the way React Hook Form does, so this is closer to a wash. Both lean on an external data hook.

function ProfilePage() {
  const { data, isLoading } = useQuery({
    queryKey: ["profile"],
    queryFn: fetchProfile,
  });
 
  if (isLoading || !data) return <p>Loading...</p>;
  return <ProfileForm initialValue={data} />;
}
 
function ProfileForm({ initialValue }: { initialValue: Profile }) {
  const form = useForm({
    initialValue,
    onSubmit: (form) => save(form.value),
  });
 
  return (
    <form onSubmit={form.handleSubmit}>
      {/* ... */}
    </form>
  );
}

What's different:

Kin FormTanStack Form
Async defaultsinitialValue is synchronous, read once at constructiondefaultValues is synchronous; docs recommend pairing with TanStack Query
Loading stateFrom your data-fetching hook (e.g. useQuery's isLoading)Same, from your data-fetching hook
Populating once loadedProfileForm mounts only after data arrives, so initialValue is already realSame: ProfileForm mounts only after data arrives, so defaultValues is real
Resetting to loaded dataform.reset(data) if the form is already mounted when it arrivesform.reset(data) likewise, in the keep-mounted variant

Both keep the form unmounted until the data is present, then pass it straight in as the initial value, so there is nothing to reconcile afterwards. TanStack's docs also show a keep-mounted variant (feed data?.field ?? "", then call form.reset(data) in an effect when it lands); either works.

Reactivity & selective re-rendering

Kin Form: FieldApi carries all of its state (value, error, touched, dirty, ...) on one object, so useWatch, via select, subscribes to any of it (or several pieces together) in one call, isolated to that node.

TanStack Form: FieldApi, FieldGroupApi, and FormApi all read from one shared @tanstack/store. Every mutation notifies every subscriber, and each subscriber runs its own selector to decide whether to re-render. Selectors do prevent re-renders effectively, the same way React Hook Form's do; the difference is the notify-everyone-then-filter model versus Kin's targeted notify (this is the "Localized subscription" row in the feature matrix).

import { type FieldApi, useWatch } from "@kintools/form-react";
 
function Field<TParentValue>({ api }: { api: FieldApi<string, TParentValue> }) {
  // One hook covers all field state (including value).
  const [value, error] = useWatch(
    api,
    (f) => [f.value, f.touched ? f.error : null] as const,
  );
 
  return (
    <label>
      <input value={value} />
      {error && <span>{error}</span>}
    </label>
  );
}
 
<Field api={form.field("email")} />;

What's different:

Kin FormTanStack Form
Notify modelTargeted: a mutation notifies only that node's subscribersOne shared store: every mutation notifies every subscriber, each filters with its selector
Value vs field stateOne useWatch(api, select) covers bothfield.state / useStore(field.store, selector) cover both, from the same store
In-render subscription<Watch api={...} select={...}>, a render-prop useWatch<form.Subscribe selector={...}>{...}</form.Subscribe>, does not re-render the parent
Deriving a valueselect: (f) => ..., deduped (shallow by default)selector: (s) => ..., deduped by the store

The end results are similar: both let a component subscribe to exactly the slice it cares about. Kin routes a notification only to the nodes that changed. TanStack runs every selector on every change and relies on the selector's return value staying equal to skip the re-render.

Form composition

Both let you build a reusable field component (leaf, group, or array) instead of repeating markup at every call site. The type-parameter shape and the number of named primitives are where they diverge.

  • Kin Form: FieldApi<TValue, TParentValue = never> decouples a field's own value type from its parent form's shape, so a component only ever needs to know TValue. TParentValue stays an opaque pass-through it never inspects.
  • TanStack Form: reusable, typed components go through createFormHook (createFormHookContexts + createFormHook giving useAppForm, withForm, withFieldGroup), plus form.AppField / form.AppForm, plus formOptions for shared config, plus FieldGroupApi for reusable groups. The lighter route (pass the field object down directly) types it as AnyFieldApi, which is FieldApi with 23 any type parameters, so the value type is gone.

INFO

To build one reusable, type-safe text field and reuse it across forms, the concepts you learn are: FieldApi (Kin) versus createFormHookContexts, createFormHook, fieldContext, useAppForm, form.AppField, useFieldContext, and for groups withFieldGroup / FieldGroupApi and the fields mapping (TanStack). The API surface matrix lists the full set.

Leaf field

import { type FieldApi, useWatch } from "@kintools/form-react";
 
function TextField<TParent>(
  { api, label }: { api: FieldApi<string, TParent>; label: string },
) {
  const field = useWatch(api);
 
  return (
    <label>
      {label}
      <TextInput
        value={field.value}
        onBlur={field.handleBlur}
        onChange={field.handleChange}
      />
      {field.invalid && field.touched && <span>{field.error}</span>}
    </label>
  );
}
 
<TextField
  api={form.field("email", { validators: required() })}
  label="Email"
/>;

What's different:

Kin FormTanStack Form
What the component receivesAn already-resolved api: FieldApi<string, TParent>, passed as a propThe field, pulled off React context via useFieldContext<string>(), valid only inside form.AppField
One-time setupNonecreateFormHookContexts() plus createFormHook({ fieldContext, formContext, fieldComponents, formComponents })useAppForm
Value-type safetyFieldApi<string, TParent>: only a string field type-checksuseFieldContext<string>(): you assert the type, or pass the field as AnyFieldApi and lose it
Call site<TextField api={form.field("email", ...)} label="Email" /><form.AppField name="email">{(field) => <field.TextField label="Email" />}</form.AppField>
Cross-form reuseSame component, unmodified; TParent is never inspectedRegistered once in fieldComponents, reused via any useAppForm from the same hook

TanStack's typed-reusable-component story is createFormHook: a one-time wiring step that produces useAppForm, plus components registered in fieldComponents that read the field off context inside form.AppField. It works well once set up, and pre-binding keeps call sites terse (<field.TextField label=... />). Kin's is a plain prop: resolve the field, pass the FieldApi down, no context and no registration.

Nested group field

A reusable component for a nested object (an address, reused for shipping and billing):

import { type FieldApi } from "@kintools/form-react";
 
type Address = { line1: string; city: string };
 
function AddressField<TParentValue>(
  { api }: { api: FieldApi<Address, TParentValue> },
) {
  return (
    <fieldset>
      <TextField api={api.field("line1")} label="Line 1" />
      <TextField api={api.field("city")} label="City" />
    </fieldset>
  );
}
 
<AddressField api={form.field("shipping")} />
<AddressField api={form.field("billing")} />

What's different:

Kin FormTanStack Form
Reusable-group primitiveSame FieldApi<Address, TParent> as any field, passed as apiwithFieldGroup({ defaultValues, render }), a distinct HOC separate from withForm and from form.Field
Binding the group to a locationform.field("shipping"), a resolved child fieldfields="shipping" prop (a path string, a key-to-path map, or createFieldMap)
Building child pathsapi.field("line1"), relative, same call as a top-level fieldgroup.AppField name="line1", relative names resolved through the fields mapping
The group's own value typeFieldApi<Address, TParent>: Address is right theredefaultValues on the HOC stands in for the shape (runtime-unused, type only)
Distinct concepts to learnOne (FieldApi)withFieldGroup, FieldGroupApi, group.AppField, the fields mapping, createFieldMap

Kin reuses one concept: a group is a FieldApi whose value happens to be an object, and api.field("line1") addresses into it exactly like a top-level field. TanStack's reusable-group path is withFieldGroup, a higher-order component with its own group object (FieldGroupApi, which is neither FormApi nor FieldApi), bound to a spot in the form through a fields prop that is a path string or an explicit key-to-path map. It is genuinely capable (the fields map even lets a group's internal shape differ from the form's), but it is another primitive with its own model.

Array field

An array also needs stable item identity across a reorder, plus its own mutation helpers:

import { type FieldApi, useWatch } from "@kintools/form-react";
 
function ItemsField<TParent>(
  { api }: { api: FieldApi<string[], TParent> },
) {
  const value = useWatch(api, (g) => g.value);
 
  return (
    <>
      {value.map((_, i) => {
        const field = api.field(`${i}`);
        return (
          <div key={field.id}>
            <TextInput value={field.value} onChange={field.handleChange} />
            <button type="button" onClick={() => api.moveItem("", i, i - 1)}>
              Move up
            </button>
            <button type="button" onClick={() => api.removeItem("", i)}>
              Remove
            </button>
          </div>
        );
      })}
      {api.error && <span>{api.error}</span>}
      <button type="button" onClick={() => api.pushItem("", "")}>Add</button>
    </>
  );
}
 
<ItemsField
  api={form.field("items", {
    validators: (g) => (g.value.length ? null : "Add at least one item"),
  })}
/>;

What's different:

Kin FormTanStack Form
What holds the arrayFieldApi whose value is an array, the same node type as any field<form.Field mode="array">, a mode flag on the field component
Mutation helpersapi.pushItem, insertItem, removeItem, moveItem, swapItems, replaceItemfield.pushValue, insertValue, removeValue, moveValue, swapValues, replaceValue
Array-level validationThe field's own validatorsvalidators on the array form.Field
Stable item identity on reorderfield.id stays with the item as indices shift, so it works as a React keyNo built-in per-item id; key by index or derive your own
Reusable componentPass a resolved FieldApi<string[], TParent> downNo resolved-node prop; the array field lives inline in the form component (or share the whole form via withForm)

The mutation helpers line up almost one to one (pushItem / pushValue, moveItem / moveValue). Two differences stand out. Kin's array is the same FieldApi as everything else, so array-level validation is just validators on that node, where TanStack switches the field into mode="array". And Kin re-keys child fields on every mutation so field.id is a stable React key across a reorder, which TanStack does not provide; its docs key rows by index.

The Kin snippet is also a reusable ItemsField taking a resolved FieldApi, the same pattern as the leaf and group fields above. TanStack's array docs keep the field inline in the form component: there is no resolved array node to hand down, only the whole form.

Multistep forms

Neither ships an official multi-step or wizard component. Kin ships a dedicated hook, useMultistep. TanStack does not; its official multi-step example hand-rolls step state.

import { useForm, useMultistep } from "@kintools/form-react";
 
type Signup = {
  credentials: { email: string; password: string };
  address: { line1: string };
};
 
function SignupWizard() {
  const form = useForm<Signup>({
    initialValue: signupDefaults,
    onSubmit: signUp,
  });
 
  // Each step name is the DeepKey of that step's own FieldApi.
  // `next()` touches, waits for validation, and gates the advance.
  const { stepName, stepField, isLastStep, next } = useMultistep(
    form,
    ["credentials", "address"] as const,
  );
 
  return (
    <form onSubmit={form.handleSubmit}>
      {stepName === "credentials" && (
        <>
          <TextField api={stepField.field("email")} label="Email" />
          <TextField api={stepField.field("password")} label="Password" />
        </>
      )}
      {stepName === "address" && (
        <TextField api={stepField.field("line1")} label="Line 1" />
      )}
      {isLastStep
        ? <button type="submit">Sign up</button>
        : <button type="button" onClick={next}>Next</button>}
    </form>
  );
}

What's different:

Kin FormTanStack Form
Dedicated wizard APIuseMultistep hookNone; hand-rolled with useState (the official example does this)
Step-validation ceremonynext(): touch, wait for validation to settle, gate the advanceawait form.validateField(name, "change") per field in the step, checked by hand
Step ↔ field mappingEach step name is the DeepKey of that step's own FieldApi (stepField)A field-name list you maintain per step (or a FieldGroup per step)
Branching / redirectingonBeforeNext returns a step name to jump toCustom step state logic
Unvalidated navigationback(), jump(index or name)Custom setStep calls

Same as React Hook Form, neither has a wizard component, but Kin ships useMultistep and TanStack does not. TanStack's Form Groups narrow the gap a little (a group per step, advancing on its onGroupSubmit when the group validates), but there is no equivalent to useMultistep's next() doing touch, wait, and gate in one call, or onBeforeNext returning a step to redirect to.