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.
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.
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>
)
}import { formOptions, type ReactFormType } from '@tanstack/react-form'
export const availabilityFormOptions = formOptions({
defaultValues: {
availability: {
opensAt: '',
closesAt: '',
},
},
})
export type AvailabilityForm = ReactFormType<typeof availabilityFormOptions>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>
)
}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')}`
}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.
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.
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.
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.
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>
)
}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>
)
}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])
}{
"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.
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.
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.
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.
This binding map adds no information when the parent form already stores compatible fields at start and end.
<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.
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 type | strict<number | null>() | loose<number | null>() |
|---|---|---|
| number | null | ✓ | ✓ |
| number | X | ✓ |
| null | X | ✓ |
| string | null | X | X |
| number | null | undefined | X | X |
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.
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',
)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>
)
}