A common criticism of TanStack Form is its verbosity out-of-the-box. While this can be useful for educational purposes — helping enforce understanding our APIs — it's not ideal in production use cases.
This guide covers the patterns that work well in Lit:
The most direct way to share field UI across multiple forms in Lit is to write a custom element that accepts the FieldApi instance as a property. The AnyFieldApi type from @tanstack/lit-form gives you a "this is some field, I don't care about the exact generics" type that's perfect for that property.
Because the field is a property of the custom element rather than something the element owns, the host needs to subscribe to the field's store so it re-renders when the field's value or metadata change. The TanStackStoreSelector reactive controller from @tanstack/lit-store does exactly that.
// text-field.ts
import { LitElement, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { TanStackStoreSelector } from '@tanstack/lit-store'
import type { AnyFieldApi } from '@tanstack/lit-form'
@customElement('text-field')
export class TextField extends LitElement {
@property({ attribute: false })
field!: AnyFieldApi
@property({ type: String })
label = ''
// Re-render whenever this field's store updates.
_selector = new TanStackStoreSelector(this, () => this.field?.store)
render() {
return html`
<label>
<div>${this.label}</div>
<input
.value=${String(this.field.state.value ?? '')}
@blur=${() => this.field.handleBlur()}
@input=${(e: Event) =>
this.field.handleChange((e.target as HTMLInputElement).value)}
/>
</label>
${this.field.state.meta.isTouched && this.field.state.meta.errors.length
? html`<div style="color: red">
${this.field.state.meta.errors.join(', ')}
</div>`
: ''}
`
}
}// text-field.ts
import { LitElement, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { TanStackStoreSelector } from '@tanstack/lit-store'
import type { AnyFieldApi } from '@tanstack/lit-form'
@customElement('text-field')
export class TextField extends LitElement {
@property({ attribute: false })
field!: AnyFieldApi
@property({ type: String })
label = ''
// Re-render whenever this field's store updates.
_selector = new TanStackStoreSelector(this, () => this.field?.store)
render() {
return html`
<label>
<div>${this.label}</div>
<input
.value=${String(this.field.state.value ?? '')}
@blur=${() => this.field.handleBlur()}
@input=${(e: Event) =>
this.field.handleChange((e.target as HTMLInputElement).value)}
/>
</label>
${this.field.state.meta.isTouched && this.field.state.meta.errors.length
? html`<div style="color: red">
${this.field.state.meta.errors.join(', ')}
</div>`
: ''}
`
}
}Use it inside the field directive's render callback by passing the field instance as a property:
import { LitElement, html } from 'lit'
import { TanStackFormController } from '@tanstack/lit-form'
import './text-field.js'
export class AppForm extends LitElement {
form = new TanStackFormController(this, {
defaultValues: { firstName: '', lastName: '' },
})
render() {
return html`
${this.form.field(
{ name: 'firstName' },
(field) => html`
<text-field label="First Name" .field=${field}></text-field>
`,
)}
${this.form.field(
{ name: 'lastName' },
(field) => html`
<text-field label="Last Name" .field=${field}></text-field>
`,
)}
`
}
}import { LitElement, html } from 'lit'
import { TanStackFormController } from '@tanstack/lit-form'
import './text-field.js'
export class AppForm extends LitElement {
form = new TanStackFormController(this, {
defaultValues: { firstName: '', lastName: '' },
})
render() {
return html`
${this.form.field(
{ name: 'firstName' },
(field) => html`
<text-field label="First Name" .field=${field}></text-field>
`,
)}
${this.form.field(
{ name: 'lastName' },
(field) => html`
<text-field label="Last Name" .field=${field}></text-field>
`,
)}
`
}
}The field parameter inside the render callback remains fully typed against the name you passed, so field.state.value and field.handleChange are still type-checked at the call site. <text-field> itself uses AnyFieldApi internally because it has to accept any field shape.
If your reusable component only ever wraps fields of a specific value type (for example, only string fields), you can narrow the property type with the generic FieldApi<...> instead of AnyFieldApi — but AnyFieldApi is the easiest option to start with and matches how the directive is exposed in render callbacks elsewhere.
TanStackStoreSelector accepts an optional second argument to scope what triggers a re-render — for example, (snapshot) => snapshot.meta.errors. Passing nothing re-renders on any change to the field's store, which is the simplest default.
Sometimes forms get very large. To keep things manageable, you can break a form across multiple custom elements that each receive the TanStackFormController as a property.
The challenge is typing that property correctly. Writing the full TanStackFormController<…> generics by hand is verbose and error-prone, so @tanstack/lit-form provides a getFormType helper.
getFormType is a type-only utility — it does no work at runtime — that takes the same FormOptions you'd pass to new TanStackFormController(...) and returns a value whose type matches the controller that those options would produce.
// shared-form.ts
import { formOptions } from '@tanstack/lit-form'
export const peopleFormOpts = formOptions({
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
})// shared-form.ts
import { formOptions } from '@tanstack/lit-form'
export const peopleFormOpts = formOptions({
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
})Then derive the property type for a child custom element from those shared options. As with reusable field elements, the child element receives the controller as a property and won't re-render automatically when the form's state changes — wire it up with TanStackStoreSelector against form.api.store:
// child-form.ts
import { LitElement, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { TanStackStoreSelector } from '@tanstack/lit-store'
import { getFormType } from '@tanstack/lit-form'
import { peopleFormOpts } from './shared-form.js'
import './text-field.js'
const formType = getFormType(peopleFormOpts)
@customElement('child-form')
export class ChildForm extends LitElement {
@property({ attribute: false })
form!: typeof formType
@property({ type: String })
title = 'Child Form'
// Re-render when the form's state changes.
_selector = new TanStackStoreSelector(this, () => this.form?.api.store)
render() {
return html`
<p>${this.title}</p>
${this.form.field(
{ name: 'firstName' },
(field) => html`
<text-field label="First Name" .field=${field}></text-field>
`,
)}
`
}
}// child-form.ts
import { LitElement, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { TanStackStoreSelector } from '@tanstack/lit-store'
import { getFormType } from '@tanstack/lit-form'
import { peopleFormOpts } from './shared-form.js'
import './text-field.js'
const formType = getFormType(peopleFormOpts)
@customElement('child-form')
export class ChildForm extends LitElement {
@property({ attribute: false })
form!: typeof formType
@property({ type: String })
title = 'Child Form'
// Re-render when the form's state changes.
_selector = new TanStackStoreSelector(this, () => this.form?.api.store)
render() {
return html`
<p>${this.title}</p>
${this.form.field(
{ name: 'firstName' },
(field) => html`
<text-field label="First Name" .field=${field}></text-field>
`,
)}
`
}
}And use it from the parent element by passing the controller as a property:
// app.ts
import { LitElement, html } from 'lit'
import { customElement } from 'lit/decorators.js'
import { TanStackFormController } from '@tanstack/lit-form'
import { peopleFormOpts } from './shared-form.js'
import './child-form.js'
@customElement('app-root')
export class AppRoot extends LitElement {
form = new TanStackFormController(this, peopleFormOpts)
render() {
return html`<child-form .form=${this.form} title="Testing"></child-form>`
}
}// app.ts
import { LitElement, html } from 'lit'
import { customElement } from 'lit/decorators.js'
import { TanStackFormController } from '@tanstack/lit-form'
import { peopleFormOpts } from './shared-form.js'
import './child-form.js'
@customElement('app-root')
export class AppRoot extends LitElement {
form = new TanStackFormController(this, peopleFormOpts)
render() {
return html`<child-form .form=${this.form} title="Testing"></child-form>`
}
}The child element gets a fully typed form property — including all of the field and group directives — without having to spell out the controller's generics by hand or maintain a hand-written type alias.
getFormType only carries types; never call its return value at runtime. Use it as typeof getFormType(opts) (or assign to a const and use typeof) and pass the actual controller instance from the parent element via the .form property.
The same pattern works for sharing a group of related fields (for example, a password + confirm-password pair) across forms. Define a small custom element that takes the form as a property typed with getFormType — keyed against just the slice of form data the group needs — and renders the relevant form.field(...) calls.
// password-fields.ts
import { LitElement, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { TanStackStoreSelector } from '@tanstack/lit-store'
import { formOptions, getFormType } from '@tanstack/lit-form'
import './text-field.js'
const passwordFormOpts = formOptions({
defaultValues: {
password: '',
confirm_password: '',
},
})
const passwordFormType = getFormType(passwordFormOpts)
@customElement('password-fields')
export class PasswordFields extends LitElement {
@property({ attribute: false })
form!: typeof passwordFormType
_selector = new TanStackStoreSelector(this, () => this.form?.api.store)
render() {
return html`
${this.form.field(
{ name: 'password' },
(field) => html`
<text-field label="Password" .field=${field}></text-field>
`,
)}
${this.form.field(
{
name: 'confirm_password',
validators: {
onChangeListenTo: ['password'],
onChange: ({ value, fieldApi }) =>
value !== fieldApi.form.getFieldValue('password')
? 'Passwords do not match'
: undefined,
},
},
(field) => html`
<text-field label="Confirm Password" .field=${field}></text-field>
`,
)}
`
}
}// password-fields.ts
import { LitElement, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { TanStackStoreSelector } from '@tanstack/lit-store'
import { formOptions, getFormType } from '@tanstack/lit-form'
import './text-field.js'
const passwordFormOpts = formOptions({
defaultValues: {
password: '',
confirm_password: '',
},
})
const passwordFormType = getFormType(passwordFormOpts)
@customElement('password-fields')
export class PasswordFields extends LitElement {
@property({ attribute: false })
form!: typeof passwordFormType
_selector = new TanStackStoreSelector(this, () => this.form?.api.store)
render() {
return html`
${this.form.field(
{ name: 'password' },
(field) => html`
<text-field label="Password" .field=${field}></text-field>
`,
)}
${this.form.field(
{
name: 'confirm_password',
validators: {
onChangeListenTo: ['password'],
onChange: ({ value, fieldApi }) =>
value !== fieldApi.form.getFieldValue('password')
? 'Passwords do not match'
: undefined,
},
},
(field) => html`
<text-field label="Confirm Password" .field=${field}></text-field>
`,
)}
`
}
}The host form just needs to include the same fields in its own defaultValues (TypeScript will check that the form property assigned via .form=${this.form} is structurally compatible with passwordFormType).