block

TanStack Form

Type-safe forms wired to Pindoba controls

A thin integration layer that wires TanStack Form to Pindoba controls. It builds on the presentational Field wrapper — Field owns the label, description, tooltip, error region, accessibility wiring, and validation-driven feedback color; this block adds the form state, validation, and value binding on top.

It ships ready-made <FormInput>, <FormSelect>, and <FormCheckbox> wrappers for the common controls, a generic <FormField> escape hatch for everything else, and a <FormSubmit> button that disables while a submission is in flight (and, opt-in, while the form is invalid). The package re-exports TanStack’s createForm, so you import everything from one place.

Available for Svelte, React, and Astro. All build on the same agnostic core (@pindoba/core-tanstack-form) over @tanstack/form-core; the wrappers and error/feedback behavior are identical.

They differ only in how you create the form, because TanStack Form is client-reactive and Astro has no runtime reactivity:

  • SveltecreateForm(...) (re-exported from the block) and pass the instance to each wrapper via form={...}.
  • ReactuseForm(...) (re-exported from the block) and pass the instance to each wrapper via form={...}.
  • Astro — wrap the controls in <pindoba-form> and create the form in a client <script> (new FormApi(...)), then assign it to the element’s .form property. Validators and onSubmit aren’t serializable, so the form definition lives in script, not markup. The custom element wires every field via bindForm and re-binds across view transitions automatically. (Prefer the lower-level bindForm(rootEl, form) directly if you’re not using the element.)

Quick start

createForm / new FormApi accept Standard Schema validators natively — Zod 3.24+, Valibot, and ArkType all work without any adapter. Pass one under validators.onChange (or onBlur / onSubmit) and the matching field messages flow straight into each control’s error region. Toggle the demo’s Code view for the full source — including the Astro client <script> that builds the form.

---
import { stack } from "@pindoba/styled-system/patterns";
import FormInput from "../FormInput.astro";
import FormSubmit from "../FormSubmit.astro";
---

<pindoba-form id="tsf-basic">
  <div class={stack({ gap: "md", direction: "column" })}>
    <FormInput name="name" label="Name" required />
    <FormInput name="email" label="Email" type="email" required />
    <FormSubmit>Create account</FormSubmit>
    <p data-form-result hidden></p>
  </div>
</pindoba-form>

<script>
  import "../pindoba-form";
  import type { PindobaFormElement } from "../pindoba-form";
  import { FormApi } from "@tanstack/form-core";
  import { z } from "zod";

  // `@tanstack/form-core` accepts Standard Schema validators natively — Zod
  // 3.24+, Valibot, ArkType all work with no adapter.
  const schema = z.object({
    name: z.string().min(2, "Use at least 2 characters."),
    email: z.string().email("Enter a valid email address."),
  });

  const root = document.querySelector<PindobaFormElement>("#tsf-basic");
  const result = root?.querySelector<HTMLElement>("[data-form-result]");

  const form = new FormApi({
    defaultValues: { name: "", email: "" },
    validators: { onChange: schema },
    onSubmit: async ({ value }) => {
      if (result) {
        result.textContent = `Submitted: ${JSON.stringify(value)}`;
        result.hidden = false;
      }
    },
  });
  form.mount();
  if (root) root.form = form;
</script>

Control wrappers

<FormInput>, <FormSelect>, and <FormCheckbox> each bind a control to a form field and forward every Field prop (label, description, required, labelTooltip, size, orientation, feedback, errorMode, …). Errors flip the control to the danger feedback color automatically.

  • Brazil

  • United States

  • Portugal

---
import { stack } from "@pindoba/styled-system/patterns";
import FormInput from "../FormInput.astro";
import FormSelect from "../FormSelect.astro";
import FormCheckbox from "../FormCheckbox.astro";
import FormSubmit from "../FormSubmit.astro";

const countries = [
  { id: "br", label: "Brazil" },
  { id: "us", label: "United States" },
  { id: "pt", label: "Portugal" },
];
---

<pindoba-form id="tsf-mixed">
  <div class={stack({ gap: "md", direction: "column" })}>
    <FormInput name="fullName" label="Full name" required />
    <FormSelect
      name="country"
      label="Country"
      items={countries}
      placeholder="Select a country"
      required
    />
    <FormCheckbox name="terms" label="Agreement" errorMode="submitted">
      I accept the terms of service
    </FormCheckbox>
    <FormSubmit>Continue</FormSubmit>
    <p data-form-result hidden></p>
  </div>
</pindoba-form>

<script>
  import "../pindoba-form";
  import type { PindobaFormElement } from "../pindoba-form";
  import { FormApi } from "@tanstack/form-core";
  import { z } from "zod";

  const schema = z.object({
    fullName: z.string().min(2, "Use at least 2 characters."),
    country: z.string().min(1, "Pick a country."),
    terms: z.literal(true, { message: "You must accept the terms." }),
  });

  const root = document.querySelector<PindobaFormElement>("#tsf-mixed");
  const result = root?.querySelector<HTMLElement>("[data-form-result]");

  const form = new FormApi({
    defaultValues: { fullName: "", country: "", terms: false },
    validators: { onChange: schema },
    onSubmit: async ({ value }) => {
      if (result) {
        result.textContent = `Submitted: ${JSON.stringify(value)}`;
        result.hidden = false;
      }
    },
  });
  form.mount();
  if (root) root.form = form;
</script>

Error visibility

By default a field’s errors appear only after it has been touched. Use errorMode to change that:

  • "touched" (default) — after the user interacts with the field.
  • "submitted" — only after a submit attempt.
  • "always" — as soon as the value is invalid.
<!-- Svelte -->
<FormCheckbox {form} name="terms" label="Agreement" errorMode="submitted">
  I accept the terms of service
</FormCheckbox>
<!-- Astro — same prop; the form lives in a client <script> -->
<FormCheckbox name="terms" label="Agreement" errorMode="submitted">
  I accept the terms of service
</FormCheckbox>

Generic FormField

For any control without a convenience wrapper (combobox, date picker, range, your own component), the generic <FormField> renders the field chrome and leaves the control to you.

  • Svelte hands you both the props bag to spread and the raw TanStack field via a control(props, field) snippet, so you wire value and handlers yourself.
  • Astro renders the control in the default slot; bindForm auto-wires any standard pindoba control it finds (input, checkbox, Select). Match the control’s id to the field with the exported fieldId(name) helper so the label associates natively.

Toggle the demo’s Code view to see the pattern.

Your public @handle.

---
import { InputRoot, InputField } from "@pindoba/astro-input";
import Affix from "@pindoba/astro-affix";
import { stack } from "@pindoba/styled-system/patterns";
import FormField from "../FormField.astro";
import { fieldId } from "../field-id";

// The generic <FormField> renders the field chrome (label, description, error
// region, a11y) and leaves the control to you — drop in any pindoba control and
// `bindForm` auto-wires it. Here input parts with a leading "@" Affix prefix
// that <FormInput> doesn't expose. Match the control id to the field id so the
// label associates natively.
---

<pindoba-form id="tsf-generic">
  <div class={stack({ gap: "md", direction: "column" })}>
    <FormField
      name="handle"
      label="Username"
      description="Your public @handle."
    >
      <InputRoot>
        <Affix decorative>@</Affix>
        <InputField id={fieldId("handle")} name="handle" />
      </InputRoot>
    </FormField>
  </div>
</pindoba-form>

<script>
  import "../pindoba-form";
  import type { PindobaFormElement } from "../pindoba-form";
  import { FormApi } from "@tanstack/form-core";
  import { z } from "zod";

  const schema = z.object({
    handle: z
      .string()
      .regex(/^@?[a-z0-9_]{2,}$/i, "Letters, numbers and underscores only."),
  });

  const root = document.querySelector<PindobaFormElement>("#tsf-generic");

  const form = new FormApi({
    defaultValues: { handle: "" },
    validators: { onChange: schema },
    onSubmit: async () => {},
  });
  form.mount();
  if (root) root.form = form;
</script>

Submit button

<FormSubmit> subscribes to the form’s submit-readiness and renders a Pindoba <Button> that disables while a submission is in flight, setting aria-busy during submit. By default it stays clickable when the form is invalid (so submitting surfaces the errors); set disableWhenInvalid to also disable it while invalid.

<!-- Svelte -->
<FormSubmit {form} emphasis="primary">Save changes</FormSubmit>
<!-- Astro — no form prop; it's bound by the surrounding <pindoba-form> -->
<FormSubmit emphasis="primary" disableWhenInvalid>Save changes</FormSubmit>

API

Creating the form

  • SveltecreateForm, re-exported from @tanstack/svelte-form. See the Svelte adapter docs.
  • AstroFormApi, re-exported from @tanstack/form-core. Build it in a client <script>, form.mount(), then assign to <pindoba-form>’s .form property (or call bindForm(rootEl, form)).

Both take the same option set (defaultValues, validators, onSubmit, async validation, field arrays, …).

Props

The reference below documents three surfaces:

  • FormField — the field-level presentation props shared by every wrapper (and the generic <FormField>), in addition to name. In Svelte you also pass the form instance via form={...}; in Astro the form is bound by the surrounding <pindoba-form>, so the wrappers take no form prop.
  • FormControl — the control-specific props for the convenience wrappers (<FormInput>, <FormSelect>, <FormCheckbox>); each applies only to the wrapper named in its description.
  • FormSubmit — props for the <FormSubmit> button.
props · 26 shown · 26 total
FormField
description
string

Help text rendered under the control and wired via `aria-describedby`.

disabled
boolean
default false

Disables the field and forwards `disabled` to the control.

errorMode
"touched""submitted""always"
default "touched"

When validation errors become visible: after the field is touched, after a submit attempt, or always.

feedback
"neutral""primary""success""warning""danger"

Explicit feedback override; defaults to error-derived `danger`, otherwise the control's own resting color.

form
AnyFormApi

Svelte / React only — the TanStack form instance (`createForm()` in Svelte, `useForm()` in React). In Astro the form is bound by the surrounding `<pindoba-form>`, so wrappers take no `form` prop.

label
string

Label text shown above (or beside) the control.

labelTooltip
stringFormSlotContent

Tooltip attached to an info trigger next to the label.

name
string

Field name — a key of the form's data.

optionalText
string

Optional-hint text after the label when not required (e.g. "(optional)").

orientation
"vertical""horizontal"
default "vertical"

Label/control layout direction.

passThrough
Record<string, unknown>

Per-slot escape hatch for Field styling and attributes.

required
boolean
default false

Renders a required `*` after the label.

size
"xs""sm""md""lg"
default "md"

Size variant, kept in sync with the control. `FormSelect`/`FormCheckbox` accept `sm`/`md`/`lg` (no `xs`).

FormControl
checked
boolean
default false

Astro FormCheckbox only — initial server-rendered checked state.

children
FormSlotContent

FormCheckbox — inline text/content beside the checkbox (Svelte snippet / Astro default slot).

control
FormSlotContent

FormField only — Svelte / React render-prop for the control. Receives the props bag to spread plus the raw TanStack field API for value/handler binding. (Astro uses the default slot instead; `bindForm` auto-wires standard controls.)

items
FormSelectItem[]

FormSelect only — the options to choose from.

placeholder
string

FormInput / FormSelect — placeholder shown when empty.

selectionMode
"single""multiple"
default "single"

FormSelect only — `"single"` (default) or `"multiple"`.

type
"number""text""search""email""password""tel""url"
default "text"

FormInput only — HTML input type forwarded to `<Input>`.

value
string

Astro only — initial server-rendered value for FormInput (string) / FormSelect. The form is the source of truth after hydration.

FormSubmit
children
FormSlotContent

Button label content. Falls back to "Submit".

disableWhenInvalid
boolean
default false

Also disable while the form is invalid (not just while submitting). Off by default, so submitting an invalid form surfaces its errors.

emphasis
"primary""secondary""tertiary""ghost"
default "primary"

Emphasis forwarded to the underlying `<Button>`.

form
AnyFormApi

Svelte / React only — the TanStack form instance (`createForm()` in Svelte, `useForm()` in React). In Astro the surrounding `<pindoba-form>` binds it.

size
"xs""sm""md""lg"

Size forwarded to the underlying `<Button>`.

Type

  • Components
  • Blocks