When building a multi-stage form that has many stages, like so:

It's common for each step to have its own form. However, this complicates the form submission and validation process by requiring you to add complex logic.
Luckily, TanStack Form provides a way to build out sub-forms that make this kind of development trivial to implement: [tanstackFormGroup].
To use a form group in TanStack Form, you'll use injectForm to create a form variable, then reference it with the tanstackFormGroup directive like you would with tanstackField:
import { Component } from '@angular/core'
import { TanStackFormGroup, injectForm } from '@tanstack/angular-form'
@Component({
selector: 'app-root',
standalone: true,
imports: [TanStackFormGroup],
template: `
<ng-container [tanstackFormGroup]="form" name="step1" #group="formGroup">
<!-- `group.api` here has all of the form-like methods you'd expect like `deleteField` or `insertFieldValue` -->
<!-- ... -->
</ng-container>
`,
})
export class AppComponent {
form = injectForm({
defaultValues: {
step1: {
name: '',
},
step2: {
age: 0,
},
},
})
}import { Component } from '@angular/core'
import { TanStackFormGroup, injectForm } from '@tanstack/angular-form'
@Component({
selector: 'app-root',
standalone: true,
imports: [TanStackFormGroup],
template: `
<ng-container [tanstackFormGroup]="form" name="step1" #group="formGroup">
<!-- `group.api` here has all of the form-like methods you'd expect like `deleteField` or `insertFieldValue` -->
<!-- ... -->
</ng-container>
`,
})
export class AppComponent {
form = injectForm({
defaultValues: {
step1: {
name: '',
},
step2: {
age: 0,
},
},
})
}This becomes much more useful when paired with external state to conditionally render a form group:
import { Component, signal } from '@angular/core'
import {
TanStackField,
TanStackFormGroup,
injectForm,
} from '@tanstack/angular-form'
@Component({
selector: 'app-root',
standalone: true,
imports: [TanStackField, TanStackFormGroup],
template: `
@if (step() === 0) {
<ng-container
[tanstackFormGroup]="form"
name="step1"
[onGroupSubmit]="onStep1Submit"
[onGroupSubmitInvalid]="onStep1SubmitInvalid"
[onSubmitMeta]="submitMeta"
#group="formGroup"
>
<form
(submit)="
$event.preventDefault();
$event.stopPropagation();
group.api.handleSubmit()
"
>
<ng-container [tanstackField]="form" name="step1.name" #name="field">
<!-- ... -->
</ng-container>
</form>
</ng-container>
}
@if (step() === 1) {
<ng-container
[tanstackFormGroup]="form"
name="step2"
[onGroupSubmit]="onStep2Submit"
#group="formGroup"
>
<form
(submit)="
$event.preventDefault();
$event.stopPropagation();
group.api.handleSubmit()
"
>
<ng-container [tanstackField]="form" name="step2.age" #age="field">
<!-- ... -->
</ng-container>
</form>
</ng-container>
}
`,
})
export class AppComponent {
step = signal(0)
submitMeta = {} as SomeType
form = injectForm({
defaultValues: {
step1: {
name: '',
},
step2: {
age: 0,
},
},
})
onStep1Submit = () => {
// We can move the step forward when validation passes
this.step.update((step) => step + 1)
}
onStep1SubmitInvalid = () => {
// Or handle invalid submissions, just like a top-level form
}
onStep2Submit = () => {
// Then, use `form.handleSubmit()` to submit the entire form
this.form.handleSubmit()
}
}import { Component, signal } from '@angular/core'
import {
TanStackField,
TanStackFormGroup,
injectForm,
} from '@tanstack/angular-form'
@Component({
selector: 'app-root',
standalone: true,
imports: [TanStackField, TanStackFormGroup],
template: `
@if (step() === 0) {
<ng-container
[tanstackFormGroup]="form"
name="step1"
[onGroupSubmit]="onStep1Submit"
[onGroupSubmitInvalid]="onStep1SubmitInvalid"
[onSubmitMeta]="submitMeta"
#group="formGroup"
>
<form
(submit)="
$event.preventDefault();
$event.stopPropagation();
group.api.handleSubmit()
"
>
<ng-container [tanstackField]="form" name="step1.name" #name="field">
<!-- ... -->
</ng-container>
</form>
</ng-container>
}
@if (step() === 1) {
<ng-container
[tanstackFormGroup]="form"
name="step2"
[onGroupSubmit]="onStep2Submit"
#group="formGroup"
>
<form
(submit)="
$event.preventDefault();
$event.stopPropagation();
group.api.handleSubmit()
"
>
<ng-container [tanstackField]="form" name="step2.age" #age="field">
<!-- ... -->
</ng-container>
</form>
</ng-container>
}
`,
})
export class AppComponent {
step = signal(0)
submitMeta = {} as SomeType
form = injectForm({
defaultValues: {
step1: {
name: '',
},
step2: {
age: 0,
},
},
})
onStep1Submit = () => {
// We can move the step forward when validation passes
this.step.update((step) => step + 1)
}
onStep1SubmitInvalid = () => {
// Or handle invalid submissions, just like a top-level form
}
onStep2Submit = () => {
// Then, use `form.handleSubmit()` to submit the entire form
this.form.handleSubmit()
}
}When you split each step into its own component, pass the parent form down with tanstack-with-form and read it in the child with injectWithForm. The child can then use [tanstackFormGroup]="withForm.form" and [tanstackField]="withForm.form" against the same parent form instance.
Form groups have a distinct validation procedure that we think makes sense for sub-forms:
@Component({
selector: 'app-root',
standalone: true,
imports: [TanStackFormGroup],
template: `
<ng-container
[tanstackFormGroup]="form"
name="step1"
[validators]="{ onChange: groupValidator }"
#group="formGroup"
>
<!-- group.api.state.meta.errorMap // {onChange: "Error" | undefined} -->
<!-- group.api.state.meta.errors // ("Error")[] -->
</ng-container>
`,
})
export class AppComponent {
form = injectForm({
defaultValues: {
step1: { name: '' },
},
})
groupValidator = () => 'Error'
}@Component({
selector: 'app-root',
standalone: true,
imports: [TanStackFormGroup],
template: `
<ng-container
[tanstackFormGroup]="form"
name="step1"
[validators]="{ onChange: groupValidator }"
#group="formGroup"
>
<!-- group.api.state.meta.errorMap // {onChange: "Error" | undefined} -->
<!-- group.api.state.meta.errors // ("Error")[] -->
</ng-container>
`,
})
export class AppComponent {
form = injectForm({
defaultValues: {
step1: { name: '' },
},
})
groupValidator = () => 'Error'
}@Component({
selector: 'app-root',
standalone: true,
imports: [TanStackFormGroup],
template: `
<ng-container
[tanstackFormGroup]="form"
name="step1"
[validators]="{ onChange: groupValidator }"
></ng-container>
`,
})
export class AppComponent {
form = injectForm({
defaultValues: {
step1: { name: '' },
},
})
groupValidator = ({ value }: { value: { name: string } }) => ({
group: value.name === 'error' ? 'Group error' : undefined,
fields: {
// Must use the name of the field relative to the FormGroup as the error key,
// to stay consistent with how standard schema works with form groups
name: value.name === 'error' ? 'Field error' : undefined,
},
})
}@Component({
selector: 'app-root',
standalone: true,
imports: [TanStackFormGroup],
template: `
<ng-container
[tanstackFormGroup]="form"
name="step1"
[validators]="{ onChange: groupValidator }"
></ng-container>
`,
})
export class AppComponent {
form = injectForm({
defaultValues: {
step1: { name: '' },
},
})
groupValidator = ({ value }: { value: { name: string } }) => ({
group: value.name === 'error' ? 'Group error' : undefined,
fields: {
// Must use the name of the field relative to the FormGroup as the error key,
// to stay consistent with how standard schema works with form groups
name: value.name === 'error' ? 'Field error' : undefined,
},
})
}import { z } from 'zod'
@Component({
selector: 'app-root',
standalone: true,
imports: [TanStackFormGroup],
template: `
<ng-container
[tanstackFormGroup]="form"
name="step1"
[validators]="{ onChange: step1Schema }"
></ng-container>
`,
})
export class AppComponent {
step1Schema = z.object({
name: z.string().min(2),
})
form = injectForm({
defaultValues: {
step1: { name: '' },
},
})
}import { z } from 'zod'
@Component({
selector: 'app-root',
standalone: true,
imports: [TanStackFormGroup],
template: `
<ng-container
[tanstackFormGroup]="form"
name="step1"
[validators]="{ onChange: step1Schema }"
></ng-container>
`,
})
export class AppComponent {
step1Schema = z.object({
name: z.string().min(2),
})
form = injectForm({
defaultValues: {
step1: { name: '' },
},
})
}The reason we don't use the full path names for fields is so that you can compose your schemas like so:
tsconst step1Schema = z.object({ name: z.string().min(2), }) const schema = z.object({ step1: step1Schema, step2: step2Schema, })const step1Schema = z.object({ name: z.string().min(2), }) const schema = z.object({ step1: step1Schema, step2: step2Schema, })And pass the step1Schema to a form group and schema to the parent form. That way, partially validated data will still flag errors if the group is bypassed.
If you want to use dynamic validation (onDynamic) with a form group, do not rely on the onDynamic validator passed to injectForm:
form = injectForm({
validationLogic: revalidateLogic(),
validators: {
// This validator will not run `onChange` when a sub-form is submitted;
// it will only run `onChange` when the form itself is submitted.
onDynamic: schema,
},
})form = injectForm({
validationLogic: revalidateLogic(),
validators: {
// This validator will not run `onChange` when a sub-form is submitted;
// it will only run `onChange` when the form itself is submitted.
onDynamic: schema,
},
})Instead, pass your sub-schema for the group to the onDynamic validation of the group itself:
@Component({
selector: 'app-root',
standalone: true,
imports: [TanStackFormGroup],
template: `
<ng-container
[tanstackFormGroup]="form"
name="step1"
[validators]="{ onDynamic: step1Schema }"
></ng-container>
`,
})
export class AppComponent {
step1Schema = step1Schema
form = injectForm({
defaultValues: {
step1: { name: '' },
},
})
}@Component({
selector: 'app-root',
standalone: true,
imports: [TanStackFormGroup],
template: `
<ng-container
[tanstackFormGroup]="form"
name="step1"
[validators]="{ onDynamic: step1Schema }"
></ng-container>
`,
})
export class AppComponent {
step1Schema = step1Schema
form = injectForm({
defaultValues: {
step1: { name: '' },
},
})
}It will treat group.api.submissionAttempts as the way to change what validator is run before/after submit.
Just like you're able to access group.api.state.meta.errors, you're also able to access the group's value using group.api.state.value. Likewise, here are some valuable properties you can access in the group.api.state.meta: