Kanzo UI
Forms

TanStack Form

Wiring TanStack Form and zod to Field — the setup, the validation modes, and the exact call for every control in the library.

The library ships no form state and no validation. Validation explains why: the library owns error presentation, your product owns error production. This page is the other half of that decision — what "your product" actually looks like when the engine is TanStack Form and the schema is zod.

Nothing here is a wrapper we ship. It is all code you write once and copy.

Both are dependencies of your app, not of @kanzo-tech/ui. The library never imports either, which is what lets the same Field sit on a plain useState, on TanStack Form, or on a SHACL engine.

Demo

Two fields, a schema, and a submit. Nothing is red until you try to save; after that, errors follow you as you type.

One line — it is what the board shows.

Approach

  • TanStack Form owns the values. useForm for the form, form.Field for each value.
  • zod owns the rules. The schema goes straight into validators — TanStack speaks Standard Schema, and so does zod 4, so there is no adapter.
  • Field owns the chrome. Label, description, error text, and every id / aria-describedby / aria-invalid between them.
  • You own three lines of wiring per field, and this page is mostly about what those three lines are for each control.

Anatomy

<form.Field name="title">
  {(field) => (
    <Field invalid={!field.state.meta.isValid}>
      <FieldLabel>Contract title</FieldLabel>
      <Input
        name={field.name}
        onBlur={field.handleBlur}
        onChange={(event) => field.handleChange(event.target.value)}
        value={field.state.value}
      />
      <FieldError>
        {field.state.meta.errors.map((issue) => issue?.message).join(", ")}
      </FieldError>
    </Field>
  )}
</form.Field>

Five things connect the two systems, and they are the same five every time:

From TanStackTo the library
field.state.valuethe control's value prop
field.handleChangethe control's change handler
field.handleBlurthe control's blur, where it has one
!field.state.meta.isValidinvalid on the Field root
field.state.meta.errorsthe children of FieldError

field.state.meta.errors is an array of Standard Schema issues — { message, path } — so the message is issue.message. It is an array because a field can fail several rules at once.

Building the form

Write the schema

zod 4, not 3. The differences that bite when you copy an older snippet:

  • z.string().email() is deprecated — it is z.email() now. Same for z.url(), z.uuid().
  • Custom messages go in { error: "…" }. { message: "…" } still works in most places, but error is the one that works everywhere, including z.enum and z.literal.
  • .min(5, "…") and friends are unchanged.
form.tsx
import * as z from "zod";

const schema = z.object({
  title: z
    .string()
    .min(10, "Give the contract a title a poster would recognise.")
    .max(60, "Keep the title under 60 characters."),
  notice: z.string().min(20, "Say what the party is walking into — at least 20 characters."),
});

Create the form

defaultValues is what gives you end-to-end type inference: every name you pass to form.Field is checked against it, and so is every handleChange.

form.tsx
import { revalidateLogic, useForm } from "@tanstack/react-form";

const form = useForm({
  defaultValues: { title: "", notice: "" },
  validationLogic: revalidateLogic(),
  validators: { onDynamic: schema },
  onSubmit: ({ value }) => post(value),
});

Submit through the form

preventDefault stops the browser navigating; stopPropagation keeps a nested form from submitting its parent. noValidate turns off the browser's own bubbles so your messages are the only ones on screen.

form.tsx
<form
  noValidate
  onSubmit={(event) => {
    event.preventDefault();
    event.stopPropagation();
    form.handleSubmit();
  }}
>
  {/* fields */}
</form>

Build the fields

One form.Field per value, one Field inside it. That is the whole shape — see the demo source above.

Validation

The schema is the validator

There is no adapter and no zodValidator import. Pass the schema, and TanStack calls it through Standard Schema:

const form = useForm({
  defaultValues: { name: "", summary: "" },
  validators: { onDynamic: schema },
});

When errors appear

This is the decision that makes a form feel considerate or hostile, and it is the one Validation says the library refuses to make for you. TanStack has two ways to make it.

The plain one is to name the moment on the validator:

KeyRuns
onChangeon every keystroke
onBlurwhen the field is left
onSubmitwhen the form is submitted

onChange alone means the form opens covered in red. onSubmit alone means the user fixes one error, submits, and discovers the next. Neither is what you want.

The better one is revalidateLogic, which changes mode once the user has tried to submit:

import { revalidateLogic } from "@tanstack/react-form";

const form = useForm({
  validationLogic: revalidateLogic(),   // before submit: nothing. after: on change.
  validators: { onDynamic: schema },
});

revalidateLogic() defaults to { mode: "submit", modeAfterSubmission: "change" } and reads the onDynamic validator instead of the named ones. It is the behaviour every example on this page uses, and it is what our own guidance — hold errors back until the user has interacted, then drop the gate on submit — looks like when the form library implements it for you.

If you would rather gate by hand, field.state.meta also carries isTouched, isBlurred, isDirty and isPristine, so invalid={field.state.meta.isBlurred && !field.state.meta.isValid} is available. Reach for it when one field needs different timing from the rest — not as the default.

Displaying errors

The three rules from Validation do not change because a form library arrived. They are just easier to break now, because the error is a value with a shape:

  1. invalid goes on the Field root, never on the control. One prop colours the label, tints the control, reveals the error and sets aria-invalid.
  2. Leave FieldError in the tree unconditionally. It renders only while the field is invalid. {!field.state.meta.isValid && <FieldError>…</FieldError>} adds nothing and creates the failure mode where a field is invalid with nothing on screen.
  3. Never write htmlFor or id. Field generates them and hands them to the control through context. name={field.name} is worth passing — for form autofill and for a native submit fallback — but it is not what associates the label.
<Field invalid={!field.state.meta.isValid} required>
  <FieldLabel>
    Contract title
    <FieldRequiredIndicator />
  </FieldLabel>
  <Input
    name={field.name}
    onBlur={field.handleBlur}
    onChange={(event) => field.handleChange(event.target.value)}
    value={field.state.value}
  />
  <FieldError>
    {field.state.meta.errors.map((issue) => issue?.message).join(", ")}
  </FieldError>
</Field>

For an error that belongs to the whole form — a rejected request, a conflict — use an Alert above the actions, not a FieldError. The complex example below does this.

Wiring each control

Here is where the real work is. Most controls in this library are Ark UI machines, and a machine does not emit a DOM event: it calls onValueChange with a details object. So event.target.value is wrong for all but four of them, and the shape of details differs per control.

The table is the reference; the examples below are the copies.

ControlValue propHandlerThe handler receives
Inputvalue: stringonChangea DOM event → event.target.value
Textareavalue: stringonChangea DOM event → event.target.value
NativeSelectvalue: stringonChangea DOM event → event.target.value
Selectvalue: string[]onValueChange{ value: string[], items }
Comboboxvalue: string[]onValueChange{ value: string[], items }
Checkboxchecked: boolean | "indeterminate"onCheckedChange{ checked }
CheckboxGroupvalue: string[]onValueChangea bare string[]
RadioGroupvalue: stringonValueChange{ value: string | null }
Switchchecked: booleanonCheckedChange{ checked }
NumberInputvalue: stringonValueChange{ value: string, valueAsNumber: number }
Slidervalue: number[]onValueChange{ value: number[] }
DatePickervalue: DateValue[]onValueChange{ value: DateValue[], valueAsString: string[] }
PinInputvalue: string[]onValueChange{ value: string[], valueAsString: string }
Ratingvalue: numberonValueChange{ value: number }
TagsInputvalue: string[]onValueChange{ value: string[] }
FileUploadacceptedFiles: File[]onFileChange{ acceptedFiles, rejectedFiles }
ColorPickervalue: stringonValueChange{ value: Color, valueAsString: string }

The exceptions to "everything reads Field". Most Ark machines pull invalid, disabled, readOnly, required and the label id out of Field's context, which is why invalid on the root is enough — Input, Textarea, NativeSelect, Select, Combobox, Checkbox, Switch, NumberInput, PinInput, Rating, TagsInput, FileUpload and ColorPicker all do.

RadioGroup, Slider and DatePicker do not. For those, pass invalid to the control as well as to the Field; it is the one place the house rule needs a second line, and it is a gap in Ark, not a choice of ours.

Input

The straightforward case, and the one you will write most: a real <input>, so a real DOM event.

Textarea

Identical to Input. Both are Ark Field parts, so they inherit their id, aria-describedby and aria-invalid from the Field root with no props from you.

0 / 280 characters.

NativeSelect

Also a real element — a <select> — so also event.target.value. Keep "" as the placeholder option's value and let the schema reject it; z.enum gives the message.

Select

The first machine, and the first surprise: Select holds an array even when it is single-select, because the same machine backs multiple. Unwrap on the way in and wrap on the way out.

It also has no blur to hook: the listbox is a portal, so focus never leaves the way it does on an input. Close is the equivalent moment, which is what onOpenChange gives you.

Combobox

Same array-shaped value as Select, plus onInputValueChange for the filter — which is not the form value and must not be wired to handleChange. The example is multi-select, so the form value and the control value are the same string[] with no unwrapping at all.

ComboboxInput is a real input, so onBlur={field.handleBlur} works here.

Checkbox

onCheckedChange gives { checked }, and checked is boolean | "indeterminate" — hence details.checked === true rather than a bare cast. For a "must accept" box, z.literal(true) says exactly that.

CheckboxGroup is the odd one out of the whole library: its callback takes the value itself, not a details object. Every other Ark control on this page takes an object.

The group's items each get their own Field orientation="horizontal" so each checkbox is bound to its own label; the outer Field carries the group's invalid and its FieldError.

Accept the hall’s standing orders

Checked when a party signs, and again when it comes back.

Who may claim it

RadioGroup

details.value is string | null, so ?? "" keeps the form value a string.

This is one of the two machines that ignore Field context, so invalid is passed twice — once to the Field for the error text, once to the RadioGroup for the control's own colour. Use RadioGroupLabel rather than FieldLabel for the group heading: the machine owns the association between the group and its name, and FieldLabel would point its htmlFor at a control that is not there.

Outcome

Radio cards

The same machine and the same wiring, with RadioGroupCard for the items — so details.value and its ?? "" are unchanged from above, and columns is what lays the cards out. It takes invalid directly, and FieldLegend inside a FieldSet gives the group its heading.

Kind of work

Switch

{ checked }, always a boolean — no indeterminate state. Note the layout idiom rather than the wiring: a horizontal Field with the label, description and error inside FieldContent, and the switch as the last child.

Any chartered hall will be able to claim it.

NumberInput

The machine's value is a string. details.valueAsNumber exists, but it is NaN while the input is empty, and pushing NaN back through String() puts the text "NaN" in the box. So keep the form value a string and let the schema convert.

Start that conversion at z.string() and .pipe() into the number rules — z.coerce.number() has an input type of unknown, which does not match the form's shape and will not typecheck. schema.parse(value) in onSubmit gives you the typed payload.

onBlur goes on NumberInputInput, not on the root.

Form value: "3" — a string, always.

Slider

The value is a number[] — one entry per thumb — even for a single-value slider. There is no blur; onValueChangeEnd fires when the drag finishes, which is the equivalent.

The second control that ignores Field context, and the one where that matters least: Slider has no invalid styling of its own, so the only signals are the label colour and the FieldError. Put anything the user has to read in the error, not in the track.

5

DatePicker

The only control whose value is not a string, a number or an array of either: value is a DateValue[]. Your form field stays a plain ISO string, and the seam is one line, because onValueChange hands you both representations at once.

Writing back costs more than reading out. details.valueAsString[0] gets you the string for free, but putting a stored string back into the picker needs parseDate from @internationalized/date — a dependency of the library, not something it re-exports, so an application that hydrates a saved date installs it directly. Wrap the parse in a try/catch: one unparseable row must not take down the form. The full shape is on Date Picker.

PinInput

One string per cell, so the value is a string[]. details.valueAsString is the joined form if you would rather keep a single string in state — pick one and stay with it, because value expects the array either way.

Six figures from the hall’s seal-book. A writ is not signed without one.

Rating

A plain number, and 0 means "not rated" — so z.number().min(1, …) is the "you have to pick one" rule.

TagsInput

{ value: string[] }. max on the control stops the user adding a sixth tag, and .max(5) in the schema says why — keep both: the control prevents, the schema explains.

night-work

FileUpload

Different names for everything: the value prop is acceptedFiles, and the callback is onFileChange with { acceptedFiles, rejectedFiles } — it fires for rejections too, which is why it is the one to sync from rather than onFileAccept.

The form value is File[], so validate the count and let the control's own accept / maxFiles do the per-file work.

Drop the surveyor’s plates here

ColorPicker

Two traps. First, the value you get back: details.valueAsString is rendered in the machine's format, which defaults to rgba — ask details.value.toString("hex") if hex is what you store. Second, the value you send in: our wrapper parses it on every render, so it has to stay a valid colour. Default it to a real colour, never "".

The brand seed the hall’s palette is derived from. Stored as #c97a1e.

A complex form

Grouped FieldSets, three kinds of choice control, a party array with two sub-fields per row, a form-level Alert for a server error, and a submit button that reports its own state through form.Subscribe.

The work

How the contract reads on the board.

Where and how bad
Party

Who signs for this contract.

Any chartered hall will be able to claim it.

Accept the hall’s standing orders

Resetting

form.reset() puts the values and the meta back — isTouched, isDirty, the error maps and, with revalidateLogic, the submission counter that decides whether errors show at all.

<Button onClick={() => form.reset()} type="button" variant="outline">
  Reset
</Button>

Pass an object to reset to something other than the original defaults — form.reset(serverValues) after a successful save, so the form is clean against what is now stored rather than against what it opened with. Anything outside the form (a submit error, an upload preview) is yours to clear alongside it.

Array fields

mode="array" on a form.Field turns it into the list's owner: it re-renders when rows are added or removed, and it carries the errors that belong to the list rather than to any row.

Party

Up to five who sign for this contract.

Structure

The array field wraps a FieldSet; each row is a separate form.Field.

<form.Field mode="array" name="party">
  {(array) => (
    <FieldSet>
      <FieldLegend variant="label">Party</FieldLegend>
      <FieldGroup>
        {array.state.value.map((signatory, index) => (
          /* one form.Field per row */
        ))}
      </FieldGroup>
    </FieldSet>
  )}
</form.Field>

Nested fields

Reach a row's own values with bracket-and-dot paths. They are type-checked against defaultValues, so a typo is a compile error rather than a silently undefined field.

<form.Field key={signatory.id} name={`party[${index}].handle`}>
  {(field) => (
    <Field invalid={!field.state.meta.isValid}>
      <FieldContent>
        <InputGroup>
          <InputGroupInput
            name={field.name}
            onBlur={field.handleBlur}
            onChange={(event) => field.handleChange(event.target.value)}
            value={field.state.value}
          />
        </InputGroup>
        <FieldError>
          {field.state.meta.errors.map((issue) => issue?.message).join(", ")}
        </FieldError>
      </FieldContent>
    </Field>
  )}
</form.Field>

Do not key rows by the index. Remove a row in the middle and every row below it gets a new key, React remounts the inputs, and whoever was typing loses focus and caret. Give each row its own id when it is created and key on that — the same rule as FieldArray's rowKey, for the same reason.

Adding and removing

pushValue and removeValue on the array field. Show the remove control only when there is more than one row, and disable the add button at the maximum rather than letting the schema explain a limit the user cannot cross anyway.

<Button
  disabled={array.state.value.length >= 5}
  onClick={() => array.pushValue(newSignatory())}
  type="button"
  variant="outline"
>
  Add member
</Button>
{array.state.value.length > 1 && (
  <InputGroupButton
    aria-label={`Remove signatory ${index + 1}`}
    onClick={() => array.removeValue(index)}
    size="icon-sm"
  >
    <XIcon />
  </InputGroupButton>
)}

insertValue, replaceValue, swapValues, moveValue and clearValues are there too, with the signatures you would guess.

Validating the array itself

.min() and .max() on the array are errors about the list, and they land on the array field's own meta — not on any row. They need their own Field and FieldError, or nobody ever sees them.

const schema = z.object({
  party: z
    .array(z.object({ id: z.string(), handle: z.string().min(1, "Name somebody.") }))
    .min(1, "Somebody has to walk it.")
    .max(5, "Five to a party at most."),
});
<Field invalid={!array.state.meta.isValid}>
  <FieldError>
    {array.state.meta.errors.map((issue) => issue?.message).join(", ")}
  </FieldError>
</Field>

What the library still does not know

Every line on this page is in your app. The library saw a boolean and a ReactNode, which is the whole of its validation contract and the reason this guide can exist without a @kanzo-tech/ui/form package to go with it.

Next: every control and how it composes, or the Field reference.

On this page