vs React Hook Form

React Hook Form is the most widely used of the three (by a wide and growing margin), so it's the one worth the deepest comparison. This page works through the same topics the guide covers, one at a time, against [email protected].

Field registration & binding model

Native input

import { useForm, Watch } from "@kintools/form-react";
import { required } from "@kintools/form-validators";
 
type LoginValues = { email: string };
 
function LoginForm() {
  const form = useForm<LoginValues>({
    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 FormReact Hook Form
Binding modelControlled everywhere (value/handleChange)Uncontrolled (register + ref) by default
One-off native input<Watch> render prop — more ceremony inline{...register(name)} — one line

For a handful of native inputs each used once, register genuinely produces less code. Kin Form's bet is the opposite: build the field components once (TextField, AddressField, SubmitButton; see Form composition below), and every call site collapses to one line too, typed against that form's value shape. That pays off fast across many forms or a shared field library; for a single one-off form, <Watch> inline is the right call.

This is the only section using a bare <input>/register, since wrapping one in Controller just to force it controlled wouldn't prove anything. Everywhere else, both sides bind to a controlled <TextInput> component, so Controller/useController only shows up where it's actually earning its keep.

Non-native input

import { useForm, Watch } from "@kintools/form-react";
import { required } from "@kintools/form-validators";
 
type ProfileValues = { country: string };
 
function ProfileForm() {
  const form = useForm<ProfileValues>({
    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 FormReact Hook Form
Non-native inputsSame Watch as any other fieldNeeds Controller — a second primitive

Nested groups and arrays are FieldApi nodes too, not a separate hook or a special case — see Array field and Nested group field under Form composition below for the full comparison, shown as reusable components rather than inlined in one form.

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

What's different:

Kin FormReact Hook Form
When validation runsvalidators run synchronously on every value change; asyncValidator debouncedForm-wide mode/reValidateMode setting
DebouncingvalidationDebounceMs — applies to asyncValidator onlyHand-rolled inside validate (no built-in option)
Rule compositionArray of validators, checked in order, first truthy winsMultiple rules for one field via register options

Schema validation

Both need a thin adapter from a separate package to plug a schema in:

  • Kin Form: toSchemaValidator() from @kintools/form-validators
  • React Hook Form: standardSchemaResolver from @hookform/resolvers

Both adapters can be used with any Standard Schema library: zod, valibot, ...

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

What's different:

Kin FormReact Hook Form
Schema scopeAny node — field, group, or the whole form — can have its own schemaValidatorOne resolver, for the whole form
Per-field rules once wired upschemaValidator runs alongside per-field validators, not instead of themresolver replaces register's own rules for the fields it covers — required/pattern/validate stop running
Where schema issues landA field's own schemaError, kept separate from error — the field decides how to combine them (field.error ?? field.schemaError)Same errors as per-field rules — schema output replaces them, so there's nothing to combine

Cross-field validation

Both support it, but from opposite ends of the relationship, and with different amounts of wiring.

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 FormReact Hook Form
Where it's declaredDeclarative on the source field (dependents list)Manual trigger() call on the source field
WiringNothing extra — dependents handles the refiregetValues() in validate to read the other value, trigger() from the source field's onChange to refire
Multiple dependentsOne dependents array covers all of themOne trigger() call per dependent field, by hand

Dirty tracking & reset

Both track dirtiness at both levels (whole form and per field), but differently.

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 level dirty */}
            {field.dirty && <span>Edited</span>}
          </>
        )}
      </Watch>
 
      {/* Form level dirty */}
      <Watch api={form} select={(f) => f.dirty}>
        {(f, dirty) => (
          <button disabled={!dirty} onClick={f.reset}>
            Discard changes
          </button>
        )}
      </Watch>
    </form>
  );
}

What's different:

Kin FormReact Hook Form
Whole-form dirtyform.dirtyformState.isDirty
Per-field dirtyfield.dirty inside a Watch/useWatch scoped to that field — the subscription unit is the field itself, not which property you readformState.dirtyFields — reading .firstName still subscribes to the whole object, so any field becoming dirty re-renders this component
Resetform.reset(value?) — moves the baseline tooreset(values?, keepStateOptions)
Reset one fieldform.resetField(name, value?) — same idearesetField(name, options?)

Submission handling

Both separate "the form failed validation" from "submission itself succeeded," but only one also separates "submission itself failed."

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 FormReact Hook Form
Validation failedonSubmitInvalidonInvalid callback (2nd arg to handleSubmit)
onSubmit itself throwsonSubmitError, invoked automaticallyRethrown after updating state — no dedicated callback
Binding to <form>onSubmit={form.handleSubmit}onSubmit={handleSubmit(onValid, onInvalid)}
Preventing page reloadAutomatic when given an event — handleSubmit's event param is optional, so the same call also works from a React Native onPress with nothing to passAutomatic — handleSubmit calls it internally

Async initial values

This is a place React Hook Form is genuinely nicer.

function ProfilePage() {
  const { data, isLoading } = useQuery({
    queryKey: ["profile"],
    queryFn: fetchProfile,
  });
 
  if (isLoading) 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 FormReact Hook Form
Async defaultsinitialValue is synchronous only, read once at constructiondefaultValues accepts an async function, resolved automatically
Loading stateWhatever your data-fetching hook already gives you (e.g. useQuery's isLoading)formState.isLoading, built in
Populating once loadedProfileForm doesn't mount until data arrives — initialValue is already the real valueAutomatic — same component, defaultValues resolves in place

Reactivity & selective re-rendering

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

React Hook Form splits value from the rest of a field's state into separate hooks instead: useWatch only watches values, so reading a field's error or touched status means also subscribing to useFormState.

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 FormReact Hook Form
Default subscriptionuseWatch(api) — isolated per field/formuseWatch({ control, name }) — isolated per value
Deriving a valueselect: (f) => ..., deduped via equal (shallow by default)compute: (value) => ..., deep-equal deduped — transforms the watched value only
Value vs field stateOne useWatch(api, select) covers bothuseWatch + useFormState — two separate hooks, combined by hand

Form composition

Both let you build a reusable field component (leaf or group/array alike) instead of repeating markup at every call site. The type parameter shape is where the two 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 TValueTParentValue stays an opaque pass-through it never inspects.
  • React Hook Form: Control<TFieldValues> parameterizes the field by the whole form instead, so a shared component built against it either re-parameterizes itself over whatever form it's dropped into (generics leaking through every reusable component's signature) or drops to loosely-typed props.

The examples below reuse TextField/AddressField within one form; the same signatures generalize across completely unrelated forms too, with zero per-form coupling.

React Hook Form also has an official addon for this gap, @hookform/lenses. A Lens<TValue> prop looks like FieldApi<TValue, TParentValue> on the surface, but it's another abstraction layer wrapping the same register/useController/ useFieldArray underneath, not a change to them. The comparison below is against core React Hook Form, without this addon.

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"
/>;

The shapes end up close in spirit (both return one component reusable across every form), but the type-safety story differs:

What's different:

Kin FormReact Hook Form
Reusable field prop bagAn already-resolved api: FieldApi<...>, passed in directlyUseControllerPropscontrol+name+rules
Type-safety on nameChecked once, where form.field(name, options) is called — not re-derived inside every componentFieldPath<T> catches a typo'd name as a compile error, per call site
Type-safety on valueFieldApi<string, TParent> — only a string-valued field type-checks, nothing extra neededNeeds FieldPathByValue<T, string> in place of FieldPath<T> — plain FieldPath<T> alone accepts a field of any value type
Cross-form reuseSame component, unmodified, across unrelated forms — TParentValue is never inspectedNeeds re-parameterizing per form's TFieldValues, or Control<any>

Nested group field

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

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 FormReact Hook Form
Reusable nested-group propAn already-resolved api: FieldApi<Address, TParentValue> — same shape as any other fieldcontrol + name — a path prefix, not a resolved field
Type-safety at the call siteAutomatic — FieldApi<Address, TParentValue> only accepts a field whose value is AddressFieldPathByValue<T, Address> gets there, but it's a library-specific escape hatch most RHF users never reach for
Building child pathsapi.field("line1") — relative, same call as any top-level fieldTemplate-string concatenation (`${name}.line1`)
Type-safety on childrenChecked through DeepKey<Address> regardless of how deep api is nestedStill needs a cast: TS can't prove a concatenated string is a member of FieldPathByValue<T, string>

Array field

An array (unlike the group above) also needs stable item identity across a reorder, plus its own mutation helpers:

import { FieldApi, useForm, useWatch } from "@kintools/form-react";
 
function ItemsField<TParentValue>(
  { api }: { api: FieldApi<string[], TParentValue> },
) {
  // Selective re-rendering.
  const [error, value] = useWatch(api, (f) => [f.error, f.value] as const);
 
  return (
    <>
      {value.map((_, i) => {
        const field = api.field(`${i}`);
        return (
          <ItemField
            key={field.id}
            api={field}
            onMoveUp={i > 0 ? () => api.moveItem("", i, i - 1) : undefined}
            onMoveDown={i < value.length - 1
              ? () => api.moveItem("", i, i + 1)
              : undefined}
            onRemove={() => api.removeItem("", i)}
          />
        );
      })}
      {error && <span>{error}</span>}
      <button onClick={() => api.pushItem("", "")}>Add</button>
    </>
  );
}
 
function ItemField(
  { api, onMoveUp, onMoveDown, onRemove }: {
    api: FieldApi<string, string[]>;
    onMoveUp?: () => void;
    onMoveDown?: () => void;
    onRemove: () => void;
  },
) {
  useWatch(api);
 
  return (
    <div>
      <TextInput
        value={api.value}
        onBlur={api.handleBlur}
        onChange={api.handleChange}
      />
      <button disabled={!onMoveUp} onClick={onMoveUp}>Move up</button>
      <button disabled={!onMoveDown} onClick={onMoveDown}>Move down</button>
      <button onClick={onRemove}>Remove</button>
    </div>
  );
}
 
function Form() {
  const form = useForm<{ items: string[] }>({ initialValue: { items: [] } });
 
  <ItemsField
    api={form.field("items", {
      validators: (g) => (g.value.length > 0 ? null : "Add at least one item"),
    })}
  />;
}

What's different:

Kin FormReact Hook Form
What holds the arrayFieldApi — the array is a nodeuseFieldArray for logic, useFormState for state
Array-level validationThe field's own validatorsuseFieldArray's own rules — a separate API
Item identity on reorderField identity follows the item via re-keyingfields[i].id from the hook
Reusable componentYes — pass a resolved FieldApi downYes — pass control+name down (or useFormContext)
TypesafetyFully type-safe — DeepKey<T> needs no cast, generic or notCasts needed — TFieldValues/TName are generic

Multistep forms

Neither ships an official multi-step/wizard component. Kin Form ships a dedicated hook instead, useMultistep; React Hook Form's docs demonstrate the hand-rolled version.

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,
  });
 
  // `stepField` is the FieldApi for the current step.
  // `next` checks if the current step is valid before advancing.
  const { stepName, stepField, isLastStep, next } = useMultistep(
    form,
    // Step names, matching form's value shape.
    ["credentials", "address"] as const,
  );
 
  return (
    <form onSubmit={form.handleSubmit}>
      {stepName === "credentials" && (
        <>
          {
            /* Field names are relative to the current step,
           so it's easy to extract a step's UI into a reusable component. */
          }
          <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 FormReact Hook Form
Step-validation ceremonyuseMultistep's next() — touch + wait + gate, built inHand-rolled per wizard (trigger([...names]))
Step ↔ field mappingEach step is a FieldApi (stepNames entries)A field-name list you maintain per step
Branching/redirectingonBeforeNext can redirect to an arbitrary step nameCustom step state logic