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:
createForm(...) (re-exported from the block) and pass the
instance to each wrapper via form={...}.useForm(...) (re-exported from the block) and pass the instance
to each wrapper via form={...}.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={...}.<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.)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><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.
---
// 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>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"
/>
<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.
---
// 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>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>
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.
control(props, field) snippet, so you wire value and handlers
yourself.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.control={(props, field) => …} render-prop, like Svelte.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.
---
// 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><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>
createForm, re-exported from @tanstack/svelte-form. See the
Svelte adapter docs.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, …).
The Props tab documents three surfaces:
<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.<FormInput>, <FormTextarea>, <FormSelect>, <FormCheckbox>); each
applies only to the
wrapper named in its description.<FormSubmit> button.