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 tone color; this block adds the form state, validation, and value binding on top.

It ships ready-made <FormInput>, <FormTextarea>, <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, Solid, and Astro. All build on the same agnostic core (@pindoba/core-tanstack-form) over @tanstack/form-core; the wrappers and error/tone behavior are identical.

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

  • Svelte — createForm(...) (re-exported from the block) and pass the instance to each wrapper via form={...}.
  • React — useForm(...) (re-exported from the block) and pass the instance to each wrapper via form={...}.
  • Solid — createForm(...) (alias useForm), exported by the block. It is a small Solid 2 adapter over @tanstack/form-core (@tanstack/solid-form targets Solid 1); 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.

---
// GENERATED from packages/demos/tanstack-form/basic.demo.tsx — edit the source, not this file.
import { FormInput, FormSubmit } from "@pindoba/astro-tanstack-form";
import { stack } from "@pindoba/styled-system/patterns";

const submitted = null as string | null;
---

<form data-ir-root="tanstack-form/basic" data-ir="1">
  <div class={stack({ gap: "md", direction: "column" })}>
    <FormInput
      name="name"
      label="Name"
      autocomplete="name"
      required
      data-ir="2"
    />
    <FormInput
      name="email"
      label="Email"
      type="email"
      autocomplete="email"
      required
      labelTooltip="We email your receipt here."
      data-ir="3"
    />
    <FormSubmit data-ir="4">Create account</FormSubmit>
    <!--ir:5-->
    {submitted && (
      <p>
        Submitted: <!--ir:6-->
        {submitted}
        <!--/ir-->
      </p>
    )}
    <!--/ir-->
  </div>
</form>

<script>
  import { hydrate } from "./_client/basic.ts";

  document
    .querySelectorAll('[data-ir-root="tanstack-form/basic"]')
    .forEach((root) => hydrate([root]));
</script>

Control wrappers

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

  • Brazil

  • United States

  • Portugal

---
// GENERATED from packages/demos/tanstack-form/mixed-controls.demo.tsx — edit the source, not this file.
import {
  FormCheckbox,
  FormInput,
  FormSelect,
  FormSubmit,
} from "@pindoba/astro-tanstack-form";
import { stack } from "@pindoba/styled-system/patterns";

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

const submitted = null as string | null;
---

<form data-ir-root="tanstack-form/mixed-controls" data-ir="1">
  <div class={stack({ gap: "md", direction: "column" })}>
    <FormInput name="fullName" label="Full name" required data-ir="2" />
    <FormSelect
      name="country"
      label="Country"
      items={countries}
      placeholder="Select a country"
      required
      data-ir="3"
    />
    <FormCheckbox
      name="terms"
      label="Agreement"
      errorMode="submitted"
      data-ir="4"
    >
      I accept the terms of service
    </FormCheckbox>
    <FormSubmit data-ir="5">Continue</FormSubmit>
    <!--ir:6-->
    {submitted && (
      <p>
        Submitted: <!--ir:7-->
        {submitted}
        <!--/ir-->
      </p>
    )}
    <!--/ir-->
  </div>
</form>

<script>
  import { hydrate } from "./_client/mixed-controls.ts";

  document
    .querySelectorAll('[data-ir-root="tanstack-form/mixed-controls"]')
    .forEach((root) => hydrate([root]));
</script>

Native attributes and autofill

Every prop that isn’t a Field prop is forwarded to the rendered control, so browsers and password managers can autofill and mobile keyboards pick the right layout: autocomplete, inputmode, autocapitalize, spellcheck, enterkeyhint, pattern, min / max / step, maxlength, data-*, and so on. The wrapper’s name is also rendered as the control’s native name. The form owns the value, and the Field owns id and the aria-* wiring, so those aren’t overridable.

  • <FormInput> / <FormTextarea> put them on the <input> / <textarea>. Consumer change/blur handlers (onChange/onBlur in React, onInput/onBlur in Solid, oninput/onblur in Svelte) run after the form’s own.
  • <FormSelect> forwards Select props (clearable, leading, …) and trigger attributes; autocomplete goes on the hidden native <select> mirror, which the browser autofills and the Select syncs from.
  • <FormCheckbox> puts them (and the field’s aria-invalid / aria-describedby / aria-errormessage) on the checkbox <input>, not its wrapping <label>.

React uses the camel-cased names (autoComplete, inputMode, maxLength); Svelte, Solid, and Astro use the HTML names (autocomplete, inputmode, maxlength).

// React
<FormInput form={form} name="email" type="email" label="Email" autoComplete="email" />
<FormSelect form={form} name="country" label="Country" items={countries} autoComplete="country" />
<!-- Svelte -->
<FormInput
  {form}
  name="password"
  type="password"
  label="Password"
  autocomplete="new-password"
/>

Textarea

<FormTextarea> is the multi-line version of <FormInput>. It has the same Field chrome, error region, and aria-invalid / aria-describedby / aria-errormessage wiring, and it passes the Textarea’s own rows, resize, and autoResize through.

What happened, and what did you expect?

---
// GENERATED from packages/demos/tanstack-form/textarea.demo.tsx — edit the source, not this file.
import {
  FormInput,
  FormSubmit,
  FormTextarea,
} from "@pindoba/astro-tanstack-form";
import { stack } from "@pindoba/styled-system/patterns";

const submitted = null as string | null;
---

<form data-ir-root="tanstack-form/textarea" data-ir="1">
  <div class={stack({ gap: "md", direction: "column" })}>
    <FormInput name="subject" label="Subject" required data-ir="2" />
    <FormTextarea
      name="message"
      label="Message"
      description="What happened, and what did you expect?"
      rows={4}
      maxlength={500}
      autoResize
      resize="none"
      required
      data-ir="3"
    />
    <FormSubmit data-ir="4">Send tone</FormSubmit>
    <!--ir:5-->
    {submitted && (
      <p>
        Submitted: <!--ir:6-->
        {submitted}
        <!--/ir-->
      </p>
    )}
    <!--/ir-->
  </div>
</form>

<script>
  import { hydrate } from "./_client/textarea.ts";

  document
    .querySelectorAll('[data-ir-root="tanstack-form/textarea"]')
    .forEach((root) => hydrate([root]));
</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.
  • React takes a control={(props, field) => …} render-prop, like Svelte.
  • Solid takes the same control={(props, field) => …} render-prop. It runs once, so read field.state.value inside JSX to keep the control in sync.

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

Your public @handle.

---
// GENERATED from packages/demos/tanstack-form/generic-field.demo.tsx — edit the source, not this file.
import { InputRoot, InputField } from "@pindoba/astro-input";
import Affix from "@pindoba/astro-affix";
import { FormField } from "@pindoba/astro-tanstack-form";
import { stack } from "@pindoba/styled-system/patterns";
---

<form data-ir-root="tanstack-form/generic-field" data-ir="1">
  <div class={stack({ gap: "md", direction: "column" })}>
    <FormField
      name="handle"
      label="Username"
      description="Your public @handle."
      data-ir="2"
    >
      <InputRoot data-ir="3">
        <Affix decorative>@</Affix>
        <InputField data-ir="4" />
      </InputRoot>
    </FormField>
  </div>
</form>

<script>
  import { hydrate } from "./_client/generic-field.ts";

  document
    .querySelectorAll('[data-ir-root="tanstack-form/generic-field"]')
    .forEach((root) => hydrate([root]));
</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

  • Svelte — createForm, re-exported from @tanstack/svelte-form. See the Svelte adapter docs.
  • Astro — FormApi, 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 Props tab 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>, <FormTextarea>, <FormSelect>, <FormCheckbox>); each applies only to the wrapper named in its description.
  • FormSubmit — props for the <FormSubmit> button.
  • Components
  • Blocks