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: