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 Form | React Hook Form | |
|---|---|---|
| Binding model | Controlled 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 Form | React Hook Form | |
|---|---|---|
| Non-native inputs | Same Watch as any other field | Needs 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 Form | React Hook Form | |
|---|---|---|
| When validation runs | validators run synchronously on every value change; asyncValidator debounced | Form-wide mode/reValidateMode setting |
| Debouncing | validationDebounceMs — applies to asyncValidator only | Hand-rolled inside validate (no built-in option) |
| Rule composition | Array of validators, checked in order, first truthy wins | Multiple 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:
standardSchemaResolverfrom@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 Form | React Hook Form | |
|---|---|---|
| Schema scope | Any node — field, group, or the whole form — can have its own schemaValidator | One resolver, for the whole form |
| Per-field rules once wired up | schemaValidator runs alongside per-field validators, not instead of them | resolver replaces register's own rules for the fields it covers — required/pattern/validate stop running |
| Where schema issues land | A 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 Form | React Hook Form | |
|---|---|---|
| Where it's declared | Declarative on the source field (dependents list) | Manual trigger() call on the source field |
| Wiring | Nothing extra — dependents handles the refire | getValues() in validate to read the other value, trigger() from the source field's onChange to refire |
| Multiple dependents | One dependents array covers all of them | One 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 Form | React Hook Form | |
|---|---|---|
| Whole-form dirty | form.dirty | formState.isDirty |
| Per-field dirty | field.dirty inside a Watch/useWatch scoped to that field — the subscription unit is the field itself, not which property you read | formState.dirtyFields — reading .firstName still subscribes to the whole object, so any field becoming dirty re-renders this component |
| Reset | form.reset(value?) — moves the baseline too | reset(values?, keepStateOptions) |
| Reset one field | form.resetField(name, value?) — same idea | 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 Form | React Hook Form | |
|---|---|---|
| Validation failed | onSubmitInvalid | onInvalid callback (2nd arg to handleSubmit) |
onSubmit itself throws | onSubmitError, invoked automatically | Rethrown after updating state — no dedicated callback |
Binding to <form> | onSubmit={form.handleSubmit} | onSubmit={handleSubmit(onValid, onInvalid)} |
| Preventing page reload | Automatic when given an event — handleSubmit's event param is optional, so the same call also works from a React Native onPress with nothing to pass | Automatic — 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 Form | React Hook Form | |
|---|---|---|
| Async defaults | initialValue is synchronous only, read once at construction | defaultValues accepts an async function, resolved automatically |
| Loading state | Whatever your data-fetching hook already gives you (e.g. useQuery's isLoading) | formState.isLoading, built in |
| Populating once loaded | ProfileForm doesn't mount until data arrives — initialValue is already the real value | Automatic — 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 Form | React Hook Form | |
|---|---|---|
| Default subscription | useWatch(api) — isolated per field/form | useWatch({ control, name }) — isolated per value |
| Deriving a value | select: (f) => ..., deduped via equal (shallow by default) | compute: (value) => ..., deep-equal deduped — transforms the watched value only |
| Value vs field state | One useWatch(api, select) covers both | useWatch + 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 knowTValue—TParentValuestays 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 Form | React Hook Form | |
|---|---|---|
| Reusable field prop bag | An already-resolved api: FieldApi<...>, passed in directly | UseControllerProps — control+name+rules |
Type-safety on name | Checked once, where form.field(name, options) is called — not re-derived inside every component | FieldPath<T> catches a typo'd name as a compile error, per call site |
| Type-safety on value | FieldApi<string, TParent> — only a string-valued field type-checks, nothing extra needed | Needs FieldPathByValue<T, string> in place of FieldPath<T> — plain FieldPath<T> alone accepts a field of any value type |
| Cross-form reuse | Same component, unmodified, across unrelated forms — TParentValue is never inspected | Needs 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 Form | React Hook Form | |
|---|---|---|
| Reusable nested-group prop | An already-resolved api: FieldApi<Address, TParentValue> — same shape as any other field | control + name — a path prefix, not a resolved field |
| Type-safety at the call site | Automatic — FieldApi<Address, TParentValue> only accepts a field whose value is Address | FieldPathByValue<T, Address> gets there, but it's a library-specific escape hatch most RHF users never reach for |
| Building child paths | api.field("line1") — relative, same call as any top-level field | Template-string concatenation (`${name}.line1`) |
| Type-safety on children | Checked through DeepKey<Address> regardless of how deep api is nested | Still 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 Form | React Hook Form | |
|---|---|---|
| What holds the array | FieldApi — the array is a node | useFieldArray for logic, useFormState for state |
| Array-level validation | The field's own validators | useFieldArray's own rules — a separate API |
| Item identity on reorder | Field identity follows the item via re-keying | fields[i].id from the hook |
| Reusable component | Yes — pass a resolved FieldApi down | Yes — pass control+name down (or useFormContext) |
| Typesafety | Fully type-safe — DeepKey<T> needs no cast, generic or not | Casts 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 Form | React Hook Form | |
|---|---|---|
| Step-validation ceremony | useMultistep's next() — touch + wait + gate, built in | Hand-rolled per wizard (trigger([...names])) |
| Step ↔ field mapping | Each step is a FieldApi (stepNames entries) | A field-name list you maintain per step |
| Branching/redirecting | onBeforeNext can redirect to an arbitrary step name | Custom step state logic |