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 (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 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 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 Form | React Hook Form | |
|---|---|---|
| Prop bag | api: FieldApi<...> prop | control + name + rules (UseControllerProps) |
| Type parameter | TParentValue, inert | T extends FieldValues, the whole form type |
| Field-path typing | resolved 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 Form | React Hook Form | |
|---|---|---|
| When validation runs | sync validators on every change; asyncValidator debounced | form-wide mode / reValidateMode |
| Debouncing | validationDebounceMs (async only) | hand-rolled inside validate, no built-in |
| Rule composition | validators array, first truthy wins | multiple 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:
standardSchemaResolverfrom@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 Form | React Hook Form | |
|---|---|---|
| Schema scope | any node (field, group, or form) | one resolver, whole form only |
| Schema + per-field rules | both run; per-node message wins in error | resolver overrides register's rules |
| Where schema issues land | schemaErrorMap, surfaced per-field through error | same 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 Form | React Hook Form | |
|---|---|---|
| Where it's declared | declarative, dependents on the source field | manual trigger() on the source field |
| Wiring | nothing extra | getValues() to read, trigger() in onChange to refire |
| Multiple dependents | one dependents array covers all | one 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 Form | React Hook Form | |
|---|---|---|
| Whole-form dirty | form.dirty | formState.isDirty |
| Per-field dirty | field.dirty | formState.dirtyFields; reading .firstName still subscribes to the whole object |
| Reset | form.reset(value?), moves the baseline | reset(values?, keepStateOptions) |
| Reset one field | form.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 Form | React Hook Form | |
|---|---|---|
| Validation failed | onSubmitInvalid | onInvalid, the 2nd arg to handleSubmit |
onSubmit itself throws | onSubmitError, automatic | rethrown after state update; no callback |
Binding to <form> | onSubmit={form.handleSubmit} | onSubmit={handleSubmit(onValid, onInvalid)} |
| Preventing page reload | automatic; 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 Form | React Hook Form | |
|---|---|---|
| Applying a set | form.setErrors(map), one call | setError per field; loop the map yourself |
| Message shape | flat-path-to-message map (same as a schema) | one name + { type, message } per call |
| Scope | any node: form or form.field("address") | the form |
| Clearing | automatic, on each field's next value change | clearErrors(name); an edit alone doesn't wipe it |
| Form-level message | "" key in the same map | setError("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 Form | React Hook Form | |
|---|---|---|
| Default subscription | useWatch(api), per field/form | useWatch({ control, name }), per value |
| Deriving a value | select: (f) => ..., shallow-deduped | compute: (value) => ..., deep-deduped, value only |
| Value vs field state | one useWatch(api, select) covers both | useWatch + 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 Form | React Hook Form | |
|---|---|---|
| Reusable nested-group prop | resolved api: FieldApi<Address, TParentValue>, like any field | control + name, a path prefix |
| Type-safety at the call site | automatic from FieldApi<Address, TParentValue> | FieldPathByValue<T, Address>, a rarely-used escape hatch |
| Building child paths | api.field("line1"), relative | template-string concat (`${name}.line1`) |
| Type-safety on children | via DeepKey<Address>, no cast | needs 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 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 | follows the item via re-keying | fields[i].id from the hook |
| Per-item input binding | api.field(i).value, read inline | a <Controller> boundary per input |
| Reusable component | pass a resolved FieldApi down | pass control + name down (or useFormContext) |
| Type-safety | DeepKey<T> needs no cast | casts 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 Form | React Hook Form | |
|---|---|---|
| Step-validation ceremony | next(): touch, wait, gate, built in | hand-rolled per wizard (trigger([...names])) |
| Step to field mapping | each step is a FieldApi | a field-name list you maintain per step |
| Branching / redirecting | onBeforeNext returns a step to jump to | custom 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 Form | React Hook Form | |
|---|---|---|
| Reset on switch | unregisterField("defaultValue", { resetValue: true }) in onValueChanged | manual setValue() inside the source's onChange |
| Per-type validation | one validators function per branch | two rules.validate, one per branch |
| Binding a narrower type | field(...) as FieldApi<boolean, ...> | name as FieldPathByValue<Attribute, boolean> |
Rendering the type picker | SelectField, no extra prop needed | SelectField needs an onSelect escape hatch |
Watching type safely | useWatch in a child of useForm | useWatch({ control, name }) in a child of useForm |
| What the child takes | one form prop; resolves defaultValue itself | one 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.