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.touched && field.invalid && <span>{field.error}</span>}
</>
)}
</Watch>
<button type="submit">Log in</button>
</form>
);
}What's different:
| Kin Form | TanStack Form | |
|---|---|---|
| Binding model | controlled (value / handleChange) | controlled (field.state.value / handleChange) |
| Field primitive | resolve then watch: form.field(...) + <Watch> | one <form.Field name>, bound to form |
| Submit wiring | onSubmit={form.handleSubmit} | handler does preventDefault + handleSubmit() |
| Reading the error | field.error, a string | null | field.state.meta.errors, an array |
Both bind controlled, and the field primitives are about the same size:
<Watch api={form.field("email", ...)}> versus <form.Field name="email" ...>.
TanStack Form fuses resolving a field and watching it into one form-bound
primitive; Kin Form keeps them separate (form.field to resolve, <Watch> /
useWatch to watch). That split is what keeps the core lean and composition
simple: a resolved FieldApi is just a value, so it drops straight into a
reusable component with a one-line call site (see
Form composition), with no form-bound component type to
thread through.
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 Form | TanStack Form | |
|---|---|---|
| Non-native inputs | Same <Watch> as any other field | Same <form.Field> as any other field |
Both treat a custom-component field exactly like a native one: no extra
primitive. Groups and arrays are where they diverge (see
Form composition): a TanStack Form FieldGroupApi is a
separate type, a Kin Form group is just a FieldApi whose value is an object.
Reusable field component
Real forms bind through reusable field components. This is where the two
libraries diverge most: in Kin Form, the call site collapses to one typed
element; in TanStack Form, a typed reusable field needs the full
createFormHook apparatus and still does not collapse the call site. So the
sections below use a <TextField> on the Kin Form side and inline
<form.Field> on the TanStack Form side, the idiomatic shape for each.
INFO
To build one reusable, type-safe text field and reuse it across forms, the concepts you learn are:
- Kin Form:
FieldApi,useWatch - Tanstack Form:
createFormHookContexts,createFormHook,fieldContext,useAppForm,form.AppField,useFieldContext, and for groupswithFieldGroup/FieldGroupApiand thefieldsmapping
The API surface matrix sets these side by side with the other libraries.
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 | TanStack Form | |
|---|---|---|
| What the component receives | resolved api: FieldApi<string, TParentValue> prop | field off React context (useFieldContext), only inside form.AppField |
| One-time setup | none | createFormHookContexts() + createFormHook({...}) → useAppForm |
| Value-type safety | FieldApi<string, TParentValue> checks it | useFieldContext<string>(): you assert it, or lose it with AnyFieldApi |
| Call site | <TextField api={form.field(...)} /> | <form.AppField name>{(field) => <field.TextField/>}</form.AppField> |
| Cross-form reuse | plain import, passed a prop | registered in one createFormHook, used via its useAppForm forms |
Kin Form: resolve the field, pass the FieldApi down. No setup, and it is the
same FieldApi every other field uses.
TanStack Form: first build the wiring (createFormHookContexts,
createFormHook, a component registry), then read the field from context inside
the component. Several APIs to learn before the first typed field renders, and
the call site still needs the form.AppField wrapper. The lighter route,
passing the field object down directly, skips the wiring but types it as
AnyFieldApi (23 any parameters), so the value type is gone.
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"
/>
);
}This is a place the two are close. Both ship a built-in async-validation
debounce (validationDebounceMs on Kin Form, asyncDebounceMs on TanStack
Form), 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 Form | TanStack Form | |
|---|---|---|
| Sync validation | one validators array, first truthy wins | validators.onChange / .onBlur, one fn per hook |
| Async validation | one asyncValidator slot | validators.onChangeAsync / .onBlurAsync |
| Debounce | validationDebounceMs | asyncDebounceMs, plus per-hook overrides |
| Config shape | one array + one async slot + one number | a validators object keyed by event |
Same coverage, different shape. Kin Form: one validators array, one async
slot, one debounce number. TanStack Form: a validators object keyed by timing
(change, blur, submit; sync and async), so "validate on blur only" is a one-key
change. Kin Form always runs sync validators on change and leaves blur-only
display to the render (field.touched).
Schema validation
This is a bundling tradeoff, not a clear win for either side: TanStack Form builds Standard Schema support into core, Kin Form keeps it in a separately versioned package for a lean core.
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),
});
// A field's own `validators` still run alongside the schema when present;
// `field.error` prefers a field's own message, falling back to its slice
// of the schema result.
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 | TanStack Form | |
|---|---|---|
| Adapter package | toSchemaValidator() from @kintools/form-validators | none; a Standard Schema is a validator itself |
| Where a schema attaches | any node, via its own schemaValidator | field or form validators.onChange |
| Schema + hand-written rules | coexist; error prefers the field's own message | merge into one errors; a field's rules replace the form schema |
| Standard Schema libraries | any (zod, valibot, arktype, ...) | any (zod, valibot, arktype, effect, ...) |
Kin Form's edge is scope and separation, not ergonomics: a schema can sit on any
node, and its output is tracked apart from a field's own validators-produced
message under the hood, so a field can carry both without one overwriting the
other; field.error surfaces whichever exists, preferring the field's own. On
TanStack Form a field-level validators entry replaces the form schema for that
field instead of 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 (
<>
<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 | TanStack Form | |
|---|---|---|
| Which field declares | the source field lists dependents | the dependent field lists onChangeListenTo |
| Reading the other value | form.value.password | fieldApi.form.getFieldValue("password") |
| Trigger granularity | dependents, re-runs on any value change | onChangeListenTo / onBlurListenTo, per event |
| Fan-out | one array on the source covers all | one array per dependent field |
Same spirit, opposite ends. Kin Form puts the wiring on the field being watched,
so adding a dependent is an edit to the source field's dependents. TanStack
Form 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.
The tradeoff is fan-out versus locality: one source feeding many dependents is a
single array in Kin Form (one onChangeListenTo per dependent in TanStack
Form), while co-locating each dependent's rule with its triggers means removing
the dependent takes its wiring with it, where a Kin Form source can be left with
a stale dependents entry.
Kin Form's side also needs no per-dependent subscription: the source's own value-change callback, which fires anyway, does the revalidation, so nothing registers or tears down as fields mount and unmount.
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 Form | TanStack Form | |
|---|---|---|
| Whole-form dirty | form.dirty, flips back on revert | form.state.isDirty, stays set after a revert |
| Differs from default now | field.dirty | !field.state.meta.isDefaultValue |
| Per-field subscription | field.dirty in a scoped Watch | field.state.meta.isDirty, from the store slice |
| Reset | form.reset(value?), moves the baseline | form.reset(values?, opts?) |
| Reset one field | form.resetField(name, value?) | form.resetField(name) |
The models differ. Kin Form's dirty is a live deepEqual against the
baseline, so typing a character then deleting it leaves the field clean.
TanStack Form's meta.isDirty means "edited at some point" and stays set after
a revert; !meta.isDefaultValue is the check that matches Kin Form's dirty.
Submission handling
Both name a callback for "the form failed validation," separate from the success path. Only Kin Form 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 Form | TanStack Form | |
|---|---|---|
| Validation failed | onSubmitInvalid | onSubmitInvalid |
onSubmit itself throws | onSubmitError, automatic | no callback; canSubmit goes false, catch it yourself |
Binding to <form> | onSubmit={form.handleSubmit} | handler does preventDefault + handleSubmit() |
| Submit-in-progress state | form.submitting | form.state.isSubmitting / .canSubmit |
onSubmitInvalid is parity. The gap: TanStack Form has no callback for "the
submit function threw," so a failed request inside onSubmit is yours to catch,
and an uncaught throw flips canSubmit to false until an input changes. Kin
Form's onSubmitError fires automatically and leaves the form submittable.
Server-side validation errors
A submit that fails a check only the server can run comes back with per-field messages to surface on the form.
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" }
},
});What's different:
| Kin Form | TanStack Form | |
|---|---|---|
| Applying a set | form.setErrors(map) | formApi.setErrorMap({ onSubmit: { form, fields } }), or return that shape from a validators.onSubmitAsync |
| Message shape | flat dotted-path → message map (same as a schema) | { form?, fields? } nested under an onSubmit key |
| Scope | any node | the form |
| Clearing | automatic, on each field's next value change | on next submit |
| Progressive-enhancement flow | none: the same call | createServerValidate + mergeForm + useTransform, in per-framework packages |
Both surface the response imperatively in onSubmit; the call is what differs.
Kin Form's setErrors takes a flat path → message map (the same shape a
schemaValidator produces), works on any node,
and clears each message once that field is edited. TanStack Form's setErrorMap
writes a { form, fields } object under the onSubmit error-map key,
form-only, cleared on the next submit.
For no-JS progressive enhancement TanStack Form adds a separate
createServerValidate/mergeForm apparatus in per-framework packages; Kin Form
has no equivalent, and no need for one, the setErrors call is the same either
way.
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 Form | TanStack Form | |
|---|---|---|
| Async defaults | initialValue is synchronous | defaultValues is synchronous |
| Loading state | from your data-fetching hook | same |
| Populating once loaded | mount the form after data arrives | same |
| Resetting to loaded data | form.reset(data) | form.reset(data) |
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
Form'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
Both support selective re-rendering; the subscription model is what differs.
Kin Form: each FieldApi node has its own subscribers, so a notification
reaches only the subscribers of the nodes that changed (and any ancestor group
being watched). State (value, error, touched, dirty, ...) lives on one
object, so a single useWatch select can subscribe to any slice of it.
TanStack Form: FieldApi, FieldGroupApi, and FormApi all read from one
shared @tanstack/store, so any change notifies every subscriber, each of
which runs its selector to decide whether to re-render. The selectors keep the
re-renders precise; the cost is only the notify-all fan-out, negligible for
typical form sizes.
The feature matrix tracks this difference as the "Localized subscription" row.
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")} />;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 FieldApi
prop; in TanStack Form each is a distinct primitive (withFieldGroup /
FieldGroupApi, form.Field with mode="array").
Group field
A reusable component for a nested object (e.g. 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 Form | TanStack Form | |
|---|---|---|
| Reusable-group primitive | same FieldApi<Address, TParentValue> as any field | withFieldGroup({ defaultValues, render }), a distinct HOC |
| Binding it to a location | form.field("shipping"), a resolved field | fields="shipping" prop (path string or key-to-path map) |
| Building child paths | api.field("line1"), relative | group.AppField name="line1", via the fields mapping |
| The group's own value type | FieldApi<Address, TParentValue>: right there | defaultValues stands in for the shape (type only) |
| Group-level validation | validators on the group's FieldApi | none on withFieldGroup; a whole-group rule must live on the form |
| Distinct concepts to learn | one (FieldApi) | withFieldGroup, FieldGroupApi, group.AppField, fields, createFieldMap |
Kin Form uses one concept: a group is a FieldApi whose value is an object, and
api.field("line1") reads into it like any other field.
TanStack Form uses withFieldGroup, a separate wrapper with its own group
object (a FieldGroupApi, which is neither FormApi nor FieldApi). You bind
it to a place in the form with a fields prop: a path string, or a map from
group key to form path. It is capable, and the map even lets the group's shape
differ from the form's, but it is one more model to learn, and it takes no
validators, so a whole-group rule has to live on the form instead of in the
component.
Array field
The per-item mechanics line up almost exactly. Two gaps: Kin Form keeps a stable React key across a reorder and TanStack Form does not, and making the array component reusable is one typed prop in Kin Form versus a choice between three imperfect options in TanStack Form.
import { type FieldApi, useWatch } from "@kintools/form-react";
function ItemsField<TParentValue>(
{ api }: { api: FieldApi<string[], TParentValue> },
) {
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 Form | TanStack Form | |
|---|---|---|
| What holds the array | the array is a FieldApi | <form.Field mode="array">, a mode flag |
| Mutation helpers | a six-method family on the field | the same six, *Value names |
| Array-level validation | the field's own validators | validators on the array field |
| Stable key across reorder | field.id follows the item | none built in; key by index |
| Reusable component | one typed prop, generic, no setup | three trade-offs (below) |
Making the array component reusable is where TanStack Form has no clean answer. Three routes, each conceding something:
withFieldGroup+ afieldsprop (above): generic and fully typed, but adds thecreateFormHooksetup, a third API object (group), and every caller option re-declared as a render prop.- Pass the whole
formtoform.Field: typed and setup-free, but pinned to one form's exact shape (ReactFormExtendedApiis invariant over its 12 type args), so one extra field breaks it. - A
{ form, name }pair with<Field>/useField(React Hook Form'sControllerstyle): generic and setup-free, but drops toAnyFormApi/ casts and ananyvalue.
Kin Form's resolved FieldApi<string[], TParentValue> prop is all three at
once: generic, typed, no setup, because TParentValue is an opaque pass-through
and validators go on form.field(name, { validators }) at the call site.
Multistep forms
Neither ships an official multi-step or wizard component. Kin Form ships a
dedicated hook, useMultistep. TanStack Form 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 Form | TanStack Form | |
|---|---|---|
| Dedicated wizard API | useMultistep hook | none; hand-rolled with useState |
| Step-validation ceremony | next(): touch, wait, gate, built in | form.validateField(...) per field, by hand |
| Step to field mapping | each step name is a DeepKey (stepField) | a field-name list you maintain per step |
| Branching / redirecting | onBeforeNext returns a step to jump to | custom step state logic |
| Unvalidated navigation | back(), jump(index or name) | custom setStep calls |
Neither ships a wizard component, but Kin Form has useMultistep and TanStack
Form does not. TanStack Form's
Form Groups
narrow the gap (a group per step, advancing on onGroupSubmit), but there is no
equivalent to next() doing touch, wait, and gate in one call, or
onBeforeNext redirecting to another step.
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: Kin Form's
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. TanStack Form's side stays inline, matching every other section on this
page - see Reusable field component for what
reusing its own field.TextField would cost here.
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 | TanStack Form | |
|---|---|---|
| Reset on switch | unregisterField("defaultValue", { resetValue: true }) in onValueChanged | listeners.onChange, on the source field |
| Revalidate on switch | not needed - each branch registers a fresh field | onChangeListenTo, on the dependent field |
| Reading the sibling | not needed - each branch registers its own validator | fieldApi.form.getFieldValue("type") |
| Per-type validation | one validators function per branch | one branching validators.onChange function |
| Reusable widget | RadioField, a prop-based component | inline radios inside form.Field's render prop |
Kin Form needs nothing declared on defaultValue itself: onValueChanged
resets it when type changes, and each branch registers it fresh with its own
validators, so nothing reads the sibling or revalidates by hand. TanStack
Form's defaultValue field stays mounted across the switch, so it still splits
the two concerns across fields - listeners resets on type,
onChangeListenTo pulls the retrigger back onto defaultValue.
DefaultValueField is split out from Kin Form's AttributeForm for the same
reason EnumOptionsField is: a component
calling useForm shouldn't also call useWatch itself. TanStack Form's
form.Field render prop doesn't have this problem, since resolving and
subscribing to a field are the same step there.