TanStack
Guides

Build Reusable React Form Sections with Field Groups

Related fields often travel together. A scheduling form might need start and end times, consistent HH:mm formatting, and a rule that prevents the end from preceding the start. Another form needs the same behavior, but stores those values at different paths.

A field group makes that component reusable by defining it around the fields it needs rather than the shape of a particular parent form. Each form can then bind its own field paths to the component.

This guide assumes you are comfortable creating fields with useForm. See the React Quick Start for that foundation. If you only need to move fields from one known form into another file, start with Splitting forms.

A section tied to one form

Suppose an availability editor stores its times at availability.opensAt and availability.closesAt. A natural first step is to extract those inputs into a component that accepts the editor's concrete form type. We can even put the blur formatter in that component so its caller does not have to repeat it.

AvailabilityTimeRange.tsx
import type { AvailabilityForm } from './form'
import { TextInput } from './TextInput'
import { formatTime } from './time-utils'

function AvailabilityTimeRange({ form }: { form: AvailabilityForm }) {
  return (
    <fieldset>
      <legend>Availability</legend>
      <form.Field
        name="availability.opensAt"
        listeners={[
          {
            triggers: ['blur'],
            run: ({ fieldApi }) => fieldApi.handleChange(formatTime),
          },
        ]}
      >
        {(field) => <TextInput field={field} label="Opens at" />}
      </form.Field>
      <form.Field
        name="availability.closesAt"
        listeners={[
          {
            triggers: ['blur'],
            run: ({ fieldApi }) => fieldApi.handleChange(formatTime),
          },
        ]}
      >
        {(field) => <TextInput field={field} label="Closes at" />}
      </form.Field>
    </fieldset>
  )
}

This is a useful component for the availability editor. It owns the input wiring and turns a value such as 9:5 into 09:05 when the field loses focus. The limitation appears when a booking form stores the same concept at schedule.departureTime and schedule.arrivalTime. Both the form type and the field names tie this component to the availability form.

We could type form as a union of every form that uses the time range, but that makes the API difficult to use. It also means constantly fixing its type as more forms need it. The time range does not care which form owns the values, it only needs certain fields with the declared value types.

Define virtual fields

Let's turn that dependency around. We have fields that need a certain type and depend on each other. defineFieldGroup lets us define the contract that we expect any given form to complete. Since the fields can come from anywhere, the field group can give them virtual names. This time range will call them start and end.

The returned definition connects that contract to a React component in two steps. Its fields value types the field group API that can be used within the component, while bindComponent creates a callable wrapper that a form can render.

TimeRangeSection.tsx
import { defineFieldGroup } from '@tanstack/react-form'

const timeRangeGroup = defineFieldGroup((helper) => ({
  // Both virtual fields must bind to concrete fields typed exactly as string.
  // To only allow the exact type, use `strict`.
  start: helper.strict<string>(),
  end: helper.strict<string>(),
}))

function TimeRangeFields(_props: {
  fields: typeof timeRangeGroup.fields
  label: string
}) {
  // ...
  return null
}

export const TimeRangeSection = timeRangeGroup.bindComponent(
  TimeRangeFields,
  // The prop that receives the field-group API.
  'fields',
)

The parent form still owns the field values and their default state.

We will fill in the component body in the next section. For now, its fields prop is limited to the declared start and end paths, including their field components, methods, and values.

Why `bindComponent`?

The selected prop keeps its name, but its role changes. Inside the component, it contains the field-group API. At the call site, it contains the virtual-to-concrete field map. bindComponent covers both the runtime as well as the types for you.

Add field behavior

Now we can implement formatting and validation against start and end. The blur listeners normalize both values, while the end validator watches start and reports an error when the end precedes it.

Run the example, enter 9:5 in either field, then move focus away. The value becomes 09:05.

/App.tsx
import { defineFieldGroup, useForm } from '@tanstack/react-form'
import { TextInput } from './TextInput'
import { formatTime, toMinutes } from './time-utils'

const timeRangeGroup = defineFieldGroup(({ strict }) => ({
  start: strict<string>(),
  end: strict<string>(),
}))

interface TimeRangeFieldsProps {
  fields: typeof timeRangeGroup.fields
  label: string
}

function TimeRangeFields({ fields, label }: TimeRangeFieldsProps) {
  return (
    <fieldset>
      <legend>{label}</legend>
      <fields.Field
        name="start"
        listeners={[
          {
            triggers: ['blur'],
            run: () => fields.setFieldValue('start', formatTime),
          },
        ]}
      >
        {(field) => <TextInput field={field} label="Start time" />}
      </fields.Field>
      <fields.Field
        name="end"
        validators={[
          {
            triggers: ['change', 'blur'],
            watchFields: ['start'],
            run: ({ value }) => {
              const start = toMinutes(fields.getFieldValue('start'))
              const end = toMinutes(value)

              if (start !== undefined && end !== undefined && end < start) {
                return 'End time must not be before the start time'
              }
            },
          },
        ]}
        listeners={[
          {
            triggers: ['blur'],
            run: () => fields.setFieldValue('end', formatTime),
          },
        ]}
      >
        {(field) => <TextInput field={field} label="End time" />}
      </fields.Field>
    </fieldset>
  )
}

const TimeRangeSection = timeRangeGroup.bindComponent(TimeRangeFields, 'fields')

export default function App() {
  const form = useForm({
    defaultValues: {
      availability: {
        opensAt: '',
        closesAt: '',
      },
      schedule: {
        departureTime: '08:30',
        arrivalTime: '10:00',
      },
    },
  })

  return (
    <main>
      <TimeRangeSection
        form={form}
        label="Availability"
        fields={{
          start: 'availability.opensAt',
          end: 'availability.closesAt',
        }}
      />
      <TimeRangeSection
        form={form}
        label="Travel schedule"
        fields={{
          start: 'schedule.departureTime',
          end: 'schedule.arrivalTime',
        }}
      />
      <form.Subscribe selector={(state) => state.values}>
        {(values) => (
          <pre aria-label="Form values">{JSON.stringify(values, null, 2)}</pre>
        )}
      </form.Subscribe>
    </main>
  )
}
Support files
/TextInput.tsx
import type { FieldWithValue } from '@tanstack/react-form'

export function TextInput({
  field,
  label,
}: {
  field: FieldWithValue<string>
  label: string
}) {
  return (
    <label>
      {label}
      <input
        name={field.name}
        value={field.value}
        onBlur={field.handleBlur}
        onChange={(event) => field.handleChange(event.target.value)}
        aria-invalid={field.meta.isInvalid}
      />
      {field.errors.map((error) => (
        <small key={error.message} role="alert">
          {error.message}
        </small>
      ))}
    </label>
  )
}
/time-utils.ts
export function formatTime(value: string) {
  const match = /^(\d{1,2}):(\d{1,2})$/.exec(value.trim())
  if (!match) return value

  const hours = Number(match[1])
  const minutes = Number(match[2])
  if (hours > 23 || minutes > 59) return value

  return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
}

export function toMinutes(value: string) {
  const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value)
  if (!match) return undefined
  return Number(match[1]) * 60 + Number(match[2])
}
/package.json
{
  "dependencies": {
    "@tanstack/react-form": "2.0.0-alpha.2",
    "react": "19.2.3",
    "react-dom": "19.2.3"
  }
}

Both time ranges apply the same formatting and validation even though their values use different paths in the parent form.

Bind the group to a form

The fields prop maps each virtual field to a concrete path. TypeScript checks that every mapping exists and that each destination satisfies its field slot.

TanStack Form applies the current map to field rendering, field methods, validators, listeners, and their watchFields. When the end validator watches start, it therefore resolves the start path for the same time-range instance.

Dynamic paths are allowed

Bindings may include array indices and other runtime paths, such as ranges[${index}].start.

The field group remains part of the parent form. It uses the parent's state, validation lifecycle, and submission. It does not create a nested form or a separate submit boundary.

Read field-group values

The range validator reads the current start time with fields.getFieldValue('start'). That is a good fit for a value needed only while an event is running.

When rendered output should react to several group values, pass fields.atom to useSelector. The selector stays typed to the virtual fields and does not subscribe the component to unrelated form values.

TimeRangeSection.tsx
import { useSelector } from '@tanstack/react-form'

interface TimeRangeSummaryProps {
  fields: typeof timeRangeGroup.fields
}

function TimeRangeSummary({ fields }: TimeRangeSummaryProps) {
  const start = useSelector(fields.atom, (state) => state.start)
  const end = useSelector(fields.atom, (state) => state.end)

  return (
    <output>
      {start} to {end}
    </output>
  )
}

fields.Subscribe remains useful for parent form metadata such as submission attempts or isSubmitting.

Avoid parent form values

Parent form values can have any shape. Read values through the field-group API instead. Form metadata remains safe to access.

Omit identity mappings

This binding map adds no information when the parent form already stores compatible fields at start and end.

OfficeHoursForm.tsx
<TimeRangeSection
  form={form}
  label="Office hours"
  fields={{
    start: 'start',
    end: 'end',
  }}
/>

In this case, omit the fields prop. Supply it only to reroute virtual names, and include every virtual field when you do.

Choose strict or loose bindings

The time-range group uses strict<string>() because its formatter and inputs expect ordinary strings. Strict slots accept only concrete fields with exactly the declared value type. This catches a surprising binding before the section can read or write it.

Use loose<T>() when the concrete field type may be narrower than T.

Concrete field typestrict<number | null>()loose<number | null>()
number | null
numberX
nullX
string | nullXX
number | null | undefinedXX

Usage in Form Composition

Since each createFormHook can define its own field components, defineFieldGroup does not know of them ahead of time. If you want to use field components in field groups, use defineAppFieldGroup instead. The remaining syntax is exactly the same.

AppTimeRangeSection.tsx
import { createFormHook } from '@tanstack/react-form'
import { TextInput } from './TextInput'

const { defineAppFieldGroup } = createFormHook({
  fieldComponents: {
    TextInput,
  },
  formComponents: {},
})

const appTimeRangeGroup = defineAppFieldGroup(({ strict }) => ({
  start: strict<string>(),
  end: strict<string>(),
}))

function AppTimeRangeFields({
  fields,
  label,
}: {
  fields: typeof appTimeRangeGroup.fields
  label: string
}) {
  return (
    <fieldset>
      <legend>{label}</legend>
      <fields.Field name="start">
        {(field) => <field.TextInput label="Start time" />}
      </fields.Field>
      <fields.Field name="end">
        {(field) => <field.TextInput label="End time" />}
      </fields.Field>
    </fieldset>
  )
}

export const AppTimeRangeSection = appTimeRangeGroup.bindComponent(
  AppTimeRangeFields,
  'fields',
)