Conditional Fields
A field's meaning can depend on a sibling. An attribute editor's "default value"
means something different depending on the selected "type": free text for
string, a <select> populated from an items array for enum. Kin Form
doesn't need a separate mechanism for this - it's one field, discriminated by
another field's current value, combining
Linked Fields (dependents),
Listeners (onValueChanged), and
Per-node Validation.
type Attribute = {
name: string;
type: "string" | "enum";
defaultValue: string;
items: { code: string; value: string }[];
};One field, several widgets
Plain conditional rendering picks the widget; the underlying field stays the
same FieldApi across every branch:
function DefaultValueField(
{ typeApi, defaultValueApi, itemsApi }: {
typeApi: FieldApi<Attribute["type"], Attribute>;
defaultValueApi: FieldApi<Attribute["defaultValue"], Attribute>;
itemsApi: FieldApi<Attribute["items"], Attribute>;
},
) {
const type = useWatch(typeApi, (f) => f.value);
const items = useWatch(itemsApi, (f) => f.value);
return type === "enum"
? (
<SelectField api={defaultValueApi}>
<option value="">Select an option…</option>
{items.map((item) => (
<option key={item.code} value={item.code}>
{item.value || item.code}
</option>
))}
</SelectField>
)
: <TextField api={defaultValueApi} />;
}Reusing one FieldApi across branches keeps the submitted shape one plain
defaultValue, instead of a separate field per type. type/defaultValue
share string here, so no cast is needed; a type with more variants (say,
adding number/boolean) still shares one field, at the cost of a cast where a
branch's widget needs a narrower value type than defaultValue's own - see the
live example below for that case.
Validating per selected type
A per-node validator reads the sibling's current value directly and branches on it, the same way any cross-field validator does:
form.field("defaultValue", {
// Fixed once, at creation - not swapped out per type on every render.
validators: (field) => {
switch (field.parent!.value.type) {
case "string":
return field.value ? null : "Default value is required";
case "enum":
return field.value ? null : "Pick a default option";
}
},
});Resetting and revalidating on switch
onValueChanged clears the stale defaultValue/items the moment type
changes; dependents makes that reset take effect immediately instead of
waiting for the user to touch defaultValue themselves:
form.field("type", {
// Revalidates defaultValue right after the reset below, so a freshly
// switched "enum" with nothing picked is already known invalid.
dependents: ["defaultValue"],
onValueChanged: (field) => {
const defaultValueField = field.parent!.field("defaultValue");
defaultValueField.value = "";
defaultValueField.touched = false; // don't show the old type's error
// Same reason: an old enum option list shouldn't resurface later.
const itemsField = field.parent!.field("items");
itemsField.value = [];
itemsField.touched = false;
},
});Fields that exist in only one branch
A field registered for a hidden branch still counts. Its validators keep
running, so a required() on it blocks submit while the user can't even see the
field. Nothing unregisters a field when its widget unmounts, so do it when the
switch happens. Create the field inline where it's rendered, so it only exists
while shown, and drop it in onValueChanged:
const typeField = form.field("type", {
onValueChanged: () => {
// Drops the company field, validator included, and clears its value.
form.unregisterField("company", { resetValue: true });
},
});
// Where the "business" branch renders it:
form.field("company", { validators: required("Company is required") });resetValue restores the value to its initial value. Without it,
unregisterField leaves the value alone. It does nothing when no field is
registered there, so it's safe to call on a switch that never showed the field.
Switching back registers a fresh field, untouched and with no stale error.
Hidden branches don't lose data on their own
Nothing clears a field just because its widget unmounted - that's why items
needed the explicit reset above; left out, switching away from "enum" and back
would show the old options again. Whether hidden data survives a switch is the
app's call, made with that one onValueChanged line. See
vs. React Hook Form and
vs. TanStack Form for how
this compares to each.
The live example adds number/boolean on top of string/enum shown here: