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 (value / handleChange)uncontrolled (register + ref) by default
One-off native input<Watch> render prop, more inline ceremony{...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 (see Form composition), and every call site collapses to one line too, typed against the form's shape. That pays off across many forms or a shared field library; for a single one-off form, <Watch> inline is fine.

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 Group field under Form composition below for the full comparison, shown as reusable components rather than inlined in one form.

Reusable field component

Real forms bind through reusable field components, not a render prop re-inlined at every call site. Every section below uses one TextField per library, built once here.

import { type FieldApi, useWatch } from "@kintools/form-react";
 
function TextField<TParentValue>(
  { api, label }: { api: FieldApi<string, TParentValue>; 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 FormReact Hook Form
Prop bagapi: FieldApi<...> propcontrol + name + rules (UseControllerProps)
Type parameterTParentValue, inertT extends FieldValues, the whole form type
Field-path typingresolved once at form.field(name)FieldPath<T> / FieldPathByValue<T, V>, per call site

Both TextFields are generic and reused unchanged across forms. The difference is that Kin Form's TParentValue carries no information (the component's real contract is FieldApi<TValue>), while React Hook Form's T is the whole form type: name is checked against it, nested paths need casts (see Group field), and Control<any> is the only way out of the generic.

Per-node validation

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

What's different:

Kin FormReact Hook Form
When validation runssync validators on every change; asyncValidator debouncedform-wide mode / reValidateMode
DebouncingvalidationDebounceMs (async only)hand-rolled inside validate, no built-in
Rule compositionvalidators array, first truthy winsmultiple rules via register options

When a field also falls under a schema, its per-node message takes precedence: field.error shows that first and the field's schema error only as a fallback.

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 } from "@kintools/form-react";
import { 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),
    onSubmit: (form) => signUp(form.value),
  });
 
  return (
    <form onSubmit={form.handleSubmit}>
      <TextField api={form.field("email")} label="Email" />
      <TextField api={form.field("password")} label="Password" />
      <button type="submit">Sign up</button>
    </form>
  );
}

What's different:

Kin FormReact Hook Form
Schema scopeany node (field, group, or form)one resolver, whole form only
Schema + per-field rulesboth run; per-node message wins in errorresolver overrides register's rules
Where schema issues landschemaErrorMap, surfaced per-field through errorsame errors; schema output replaces the per-field ones

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 (
    <>
      <TextField
        api={form.field("password", { dependents: ["confirmPassword"] })}
        label="Password"
      />
      <TextField
        api={form.field("confirmPassword", {
          validators: (field) =>
            field.value !== form.value.password ? "Passwords must match" : null,
        })}
        label="Confirm password"
      />
    </>
  );
}

What's different:

Kin FormReact Hook Form
Where it's declareddeclarative, dependents on the source fieldmanual trigger() on the source field
Wiringnothing extragetValues() to read, trigger() in onChange to refire
Multiple dependentsone dependents array covers allone trigger() call per dependent, 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.dirtyformState.dirtyFields; reading .firstName still subscribes to the whole object
Resetform.reset(value?), moves the baselinereset(values?, keepStateOptions)
Reset one fieldform.resetField(name, value?)resetField(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, the 2nd arg to handleSubmit
onSubmit itself throwsonSubmitError, automaticrethrown after state update; no callback
Binding to <form>onSubmit={form.handleSubmit}onSubmit={handleSubmit(onValid, onInvalid)}
Preventing page reloadautomatic; event arg optional (works from RN onPress)automatic, inside handleSubmit

Server-side validation errors

A submit that fails a check only the server can run (uniqueness, cross-record rules) comes back with per-field messages. Kin Form takes the whole map in one call; React Hook Form sets them one field at a time.

const form = useForm<Signup>({
  initialValue: { email: "", password: "" },
  onSubmit: async (form) => {
    const res = await signUp(form.value);
    if (res.fieldErrors) form.setErrors(res.fieldErrors); // { email: "Taken" }
  },
});
 
<form onSubmit={form.handleSubmit}>;

What's different:

Kin FormReact Hook Form
Applying a setform.setErrors(map), one callsetError per field; loop the map yourself
Message shapeflat-path-to-message map (same as a schema)one name + { type, message } per call
Scopeany node: form or form.field("address")the form
Clearingautomatic, on each field's next value changeclearErrors(name); an edit alone doesn't wipe it
Form-level message"" key in the same mapsetError("root.server", ...), a reserved name

Kin Form: setErrors takes the same flat-path-to-message shape a schemaValidator produces, on any node, and each message clears itself once the user edits that field.

React Hook Form: setError is per-call and stays until clearErrors: more bookkeeping for a multi-field response, more explicit control over when it clears.

With a React 19 Server Action, both wire the same useEffect to sync the returned state into the form. Its body is form.setErrors(state.fieldErrors) for Kin Form, the setError loop for React Hook Form.

Async initial values

React Hook Form accepts an async function for defaultValues and exposes its pending state as formState.isLoading. Kin Form takes only a synchronous initialValue, by design: fetching data is the app's job, and apps do it in different ways (a query hook, a router loader, a server component, a plain fetch). The form only needs the value once it exists, so the usual pattern is to keep it unmounted until then.

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

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).
  // An optional selector can be provided for selective re-rendering.
  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), per field/formuseWatch({ control, name }), per value
Deriving a valueselect: (f) => ..., shallow-dedupedcompute: (value) => ..., deep-deduped, value only
Value vs field stateone useWatch(api, select) covers bothuseWatch + useFormState, combined by hand

Form composition

TextField above is the leaf case. Groups and arrays add more: a group needs child-path addressing, an array also needs stable item identity across a reorder. In Kin Form, each stays one typed prop. In React Hook Form, each needs Control<TFieldValues> threaded through, plus a path-prefix convention and casts.

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.

Group field

A reusable component for a nested object (e.g. an address, reused for both 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 FormReact Hook Form
Reusable nested-group propresolved api: FieldApi<Address, TParentValue>, like any fieldcontrol + name, a path prefix
Type-safety at the call siteautomatic from FieldApi<Address, TParentValue>FieldPathByValue<T, Address>, a rarely-used escape hatch
Building child pathsapi.field("line1"), relativetemplate-string concat (`${name}.line1`)
Type-safety on childrenvia DeepKey<Address>, no castneeds a cast; TS can't prove the concatenated path is valid

Array field

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

import { type FieldApi, useForm, useWatch } from "@kintools/form-react";
 
function ItemsField<TParentValue>(
  { api }: { api: FieldApi<string[], TParentValue> },
) {
  const value = useWatch(api, (f) => f.value);
 
  return (
    <>
      {value.map((_, i) => {
        const field = api.field(`${i}`);
        return (
          <div key={field.id}>
            <TextInput
              value={field.value}
              onBlur={field.handleBlur}
              onChange={field.handleChange}
            />
            <button
              disabled={i === 0}
              onClick={() => api.moveItem("", i, i - 1)}
            >
              Move up
            </button>
            <button
              disabled={i === value.length - 1}
              onClick={() => api.moveItem("", i, i + 1)}
            >
              Move down
            </button>
            <button onClick={() => api.removeItem("", i)}>Remove</button>
          </div>
        );
      })}
      {api.error && <span>{api.error}</span>}
      <button onClick={() => api.pushItem("", "")}>Add</button>
    </>
  );
}
 
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 reorderfollows the item via re-keyingfields[i].id from the hook
Per-item input bindingapi.field(i).value, read inlinea <Controller> boundary per input
Reusable componentpass a resolved FieldApi downpass control + name down (or useFormContext)
Type-safetyDeepKey<T> needs no castcasts needed; TFieldValues / TName are generic

Both item UIs are inlined here.

Kin Form: a bare <TextInput> works because the row just reads field.value and calls stable methods, no subscription of its own; the parent's useWatch re-renders the list. If a row needs its own reactivity, swap in the reusable <TextField> (it subscribes to api internally). A dedicated component is only needed to render something custom.

React Hook Form: each input needs its own <Controller> render prop (or register, uncontrolled), there's no inline read of a field's value. Only the hook form, useController, forces a separate component.

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 ceremonynext(): touch, wait, gate, built inhand-rolled per wizard (trigger([...names]))
Step to field mappingeach step is a FieldApia field-name list you maintain per step
Branching / redirectingonBeforeNext returns a step to jump tocustom step state logic

Conditional fields

A field's meaning can depend on a sibling: picking type: "boolean" should turn defaultValue into a radio group, reset its stale value, and validate it differently than type: "string" does. The point here is AttributeForm and DefaultValueField, not another field component: SelectField/RadioField follow the exact same shape as the TextField already built above, so their bodies aren't repeated below - see the source or try it live for the real, four-type version.

import { type FieldApi, useForm, useWatch } from "@kintools/form-react";
 
type Attribute = {
  type: "string" | "boolean";
  defaultValue: string | boolean | null;
};
 
function AttributeForm() {
  const form = useForm<Attribute>({
    initialValue: { type: "string", defaultValue: "" },
    onSubmit: (form) => save(form.value),
  });
 
  const typeField = form.field("type", {
    onValueChanged: () => {
      form.unregisterField("defaultValue", { resetValue: true });
    },
  });
 
  return (
    <form onSubmit={form.handleSubmit}>
      <SelectField api={typeField} label="Type">
        <option value="string">String</option>
        <option value="boolean">Boolean</option>
      </SelectField>
      <DefaultValueField form={form} />
      <button type="submit">Save</button>
    </form>
  );
}
 
// Split from AttributeForm: a component calling useForm shouldn't also call
// useWatch itself: everything below it that needs to re-render on a field's
// value should watch it as a child instead.
function DefaultValueField({ form }: { form: FieldApi<Attribute> }) {
  const type = useWatch(form, (f) => f.value.type);
 
  return type === "boolean"
    ? (
      <RadioField
        api={form.field("defaultValue", {
          validators: (field) =>
            typeof field.value === "boolean" ? null : "Pick true or false",
        }) as FieldApi<boolean, Attribute>}
        label="Default value"
      />
    )
    : (
      <TextField
        api={form.field("defaultValue", {
          validators: (field) => field.value ? null : "Required",
        }) as FieldApi<string, Attribute>}
        label="Default value"
      />
    );
}

What's different:

Kin FormReact Hook Form
Reset on switchunregisterField("defaultValue", { resetValue: true }) in onValueChangedmanual setValue() inside the source's onChange
Per-type validationone validators function per branchtwo rules.validate, one per branch
Binding a narrower typefield(...) as FieldApi<boolean, ...>name as FieldPathByValue<Attribute, boolean>
Rendering the type pickerSelectField, no extra prop neededSelectField needs an onSelect escape hatch
Watching type safelyuseWatch in a child of useFormuseWatch({ control, name }) in a child of useForm
What the child takesone form prop; resolves defaultValue itselfone control prop; addresses defaultValue by name

Kin Form: onValueChanged unregisters and resets defaultValue when type changes; each branch then registers it fresh with its own validators, so neither branch needs to know about the other. DefaultValueField is split out for the same reason EnumOptionsField is in the Conditional Fields guide: a component calling useForm shouldn't also call useWatch itself.

React Hook Form: each typed component already validates just its own branch, so the cost shows up elsewhere: name needs the same kind of cast Kin Form's field() result does, and the reset has nowhere declarative to live, so SelectField grows an onSelect prop just to carry it.