Guides

Form Composition

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:

  • Building reusable field UI as custom elements that accept the FieldApi as a property.
  • Splitting big forms across multiple custom elements while keeping the form property fully typed via getFormType.

Reusable field components with AnyFieldApi

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.

ts
// 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:

ts
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.

Breaking big forms into smaller pieces

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.

ts
// 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:

ts
// 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:

ts
// 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.

Reusing groups of fields across multiple forms

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.

ts
// 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).