You can find me on Twitter at @tim_deschryver | Subscribe to the Newsletter | This article was first published on timdeschryver.dev.
Angular offers two distinct approaches for creating forms: template-driven and reactive.
Even though they feel quite different in practice, both approaches are constructed over the same core Forms API.
Ever since I started working with Angular, my go-to choice has been Reactive Forms.
The reason is straightforward: the Angular documentation pushes reactive forms (framing them as more scalable, reusable, and testable), and the vast majority of community tutorials and articles focus on the reactive methodology.
For a long time, template-driven forms were something I simply ignored. However, a couple of events happening within the same week pushed me to finally take a closer look at this alternative approach.
The first trigger was revisiting a complicated form after months away and finding it hard to understand how everything was wired together. That frustration led me to experiment with creating an abstraction above the Reactive Forms API. Initially, I was quite pleased with the design, but each new feature made the solution more unwieldy. Looking back at that experiment, I now understand I was reinventing a clunkier version of what template-driven forms already offer out of the box.
The second event was hearing Ward Bell champion template-driven forms during a Forms Episode of the The Angular Show podcast.
Drawing from Ward's expertise, the topics covered in that podcast (along with a StackBlitz demo), and the lessons from my own attempt to build an additional layer on top of Angular's Forms API, I have now started to uncover the advantages of taking the template-driven route.
This article walks you through what I've learned using practical, real-world examples.
All the code referenced here is available on GitHub.
Please note that this guide is a living document and still under development. In the coming weeks, I anticipate adding sections on validation, nested forms, testing strategies for template-driven forms, control value accessors, and dynamic form scenarios. If there's a topic you'd like me to cover, or if you have any feedback, don't hesitate to contact me on Twitter or open an issue on GitHub.
Creating a form
If you're new to building template-driven forms, or perhaps just need to jog your memory, the Angular docs are the best place to begin. For those wanting a deeper dive into the internal workings and data flow of template-driven forms, the documentation also provides a helpful resource on Data flow in template-driven forms.
According to the official documentation, every HTML form element automatically triggers the creation of a new NgForm instance—this happens through a built-in Angular directive that selects the form tag. Within that form, the ngModel directive is responsible for linking individual form controls to the parent form instance; behind the scenes, ngModel instantiates a FormControl (as evidenced in the linked source code). When you apply the ngModel attribute to a control, you must also set a name attribute—this is essential for constructing the form tree correctly. The string you assign to name becomes the key for that control in the template model, mapping to the corresponding form control instance.
Here’s a concrete example of that implementation.
@Component({
template: `
<form>
<label for="text">A label</label>
<input type="text" id="text" name="formText" ngModel />
</form>
`
})
export class AppComponent {}
Although the
forattribute on the label and theidattribute on the input element have no bearing on the Angular form itself, associating them is still crucial for ensuring the form is accessible.
From this form we obtain the corresponding form value.
{
"formText": ""
}
To get the most out of template-driven forms, we rely on two-way binding, which connects the template form to a TypeScript (data) model. That TypeScript model then handles what happens once a user submits the form, like triggering a backend request. Depending on the situation, we can either send that model directly or convert it into a structure the backend API expects.
Since the TypeScript model and the template model stay in sync, updating one always updates the other — the link works in both directions.
To set up this two-way binding, we apply the "banana in a box" syntax ([()]), which changes the form to the following.
@Component({
template: `
<form>
<label for="text">Text</label>
<input type="text" id="text" name="formText" [(ngModel)]="model.text" />
</form>
`,
})
export class AppComponent {
model = {
text: null,
}
}
The template and model shown above produce the two structures outlined below.
Pay attention to how the property names differ:
-
formTextapplies to the template model, since the control is namedformTexton the input - while
textbelongs to the TypeScript model, as that is the property defined in the model
| Template Form Value | TypeScript Model Value |
|---|---|
{
formText: 'some text value here'
}
|
{
text: 'some text value here'
}
|
Since the template model and the TypeScript model don't have to align in structure, you gain major flexibility compared to reactive forms—a point we'll dive into further in the upcoming sections.
Form Building Blocks
Before you can assemble a working form, you'll need a handful of fundamental controls. Here, we're going to explore how to build the typical controls and see what their values look like in both models.
Input Controls
Native input controls are the most straightforward—they're plain HTML elements with a built-in value property. To link one to the TypeScript model, simply apply the ngModel directive.
Angular simplifies things further by automatically converting the input's value into the appropriate type. This is achieved through a set of directives, specifically, control value accessors. For instance, with a number input, the value (which starts as a string) is transformed into an actual number by the number value accessor.
@Component({
template: `
<form>
<label for="text">Text</label>
<input type="text" id="text" name="formText" [(ngModel)]="model.text" />
<label for="number">Number</label>
<input type="number" id="number" name="formNumber" [(ngModel)]="model.number" />
</form>
`
})
export class AppComponent {
model = {
text: null,
number: null
};
}
| Template Form Value | TypeScript Model Value |
|---|---|
{
formText: 'hello',
formNumber: 5
}
|
{
text: 'hello',
number: 5
}
|
Select Element
Even though the native HTML select element lacks a value property, the ngModel directive still allows us to connect it to a property in the TypeScript model.
Each option element gets its value through the value attribute.
These options may either be fixed or dynamically generated with the *ngFor directive.
Once an option is chosen by the user, its set value becomes the new value of the corresponding TypeScript model property.
In the reverse direction, when the model property is given an initial value or is later updated, Angular automatically highlights the matching option in the view.
@Component({
template: `
<label for="select">Select</label>
<select id="select" name="formSelect" [(ngModel)]="model.select">
<option [value]="null">Default Option</option>
<option *ngFor="let option of options" [value]="option.value">
{{ option.label }}
</option>
</select>
`
})
export class AppComponent {
model = {
select: null
};
options = [
{
value: 1,
label: 'Option One'
},
{
value: 2,
label: 'Option Two'
},
{
value: 3,
label: 'Option Three'
}
];
}
| Template Form Value | TypeScript Model Value |
|---|---|
{
formSelect: 2
}
|
{
select: 2
}
|
Checkbox List
When dealing with checkbox lists, my approach involves defining the checkbox items directly in the TypeScript model, where each item carries a selected field that signals its current checked state. In the template, that selected field gets wired up to a checkbox input through the ngModel directive.
Keep the names of all checkboxes inside one group unique; if they clash, every control gets merged into a single form control instance, making them all share one value.
@Component({
template: `
<label>Checkbox list</label>
<div *ngFor="let check of model.checks">
<input
type="checkbox"
[id]="'formCheckbox-' + check.id"
[name]="'formCheckbox-' + check.id"
[(ngModel)]="check.selected"
/>
<label [for]="'formCheckbox-' + check.id">{{ check.label }}</label>
</div>
`
})
export class AppComponent {
model = {
checks: [
{
id: 'check-one',
label: 'Check One',
selected: false
},
{
id: 'check-two',
label: 'Check Two',
selected: false
},
{
id: 'check-three',
label: 'Check Three',
selected: false
}
]
};
}
| Template Form Value | TypeScript Model Value |
|---|---|
{
formCheckbox-check-one: false,
formCheckbox-check-two: true,
formCheckbox-check-three: true,
}
|
{
checks: [
{
id: 'check-one',
label: 'Check One',
selected: false
},
{
id: 'check-two',
label: 'Check Two',
selected: true
},
{
id: 'check-three',
label: 'Check Three',
selected: true
}
]
}
|
In the snippet above, the checkbox values are stored in a flat object structure.
That approach works fine for straightforward scenarios, but we have the option to restructure the template model by introducing nested objects.
The template model isn't bound to mirror the TypeScript model, so we can freely adapt the structure to suit the form's needs.
This gives us the flexibility to shape the template for specific use-cases.
Personally, I prefer organizing the checkboxes into a nested hierarchy, which makes it straightforward to validate the checkbox group, for instance, when ensuring at least one checkbox is selected.
The following example leverages the ngModelGroup directive to nest the checkboxes. Internally, Angular generates a fresh FormGroup instance and inserts a new leaf labeled with the provided name into the template model.
This adjustment does not affect the TypeScript model; it merely alters the template model to simplify usage, such as making it easier to validate.
@Component({
template: `
<label>Checkbox list</label>
<div *ngFor="let check of model.checks" ngModelGroup="formCheckbox">
<input
type="checkbox"
[id]="'formCheckbox-' + check.id"
[name]="check.id"
[(ngModel)]="check.selected"
/>
<label [for]="'formCheckbox-' + check.id">{{ check.label }}</label>
</div>
`,
})
export class AppComponent {
model = {
checks: [
{
id: 'check-one',
label: 'Check One',
selected: false,
},
{
id: 'check-two',
label: 'Check Two',
selected: false,
},
{
id: 'check-three',
label: 'Check Three',
selected: false,
},
],
}
}
As a result of this modification, the template model and the TypeScript model now take on the shapes described below.
| Template Form Value | TypeScript Model Value |
|---|---|
{
formCheckbox: {
check-one: false,
check-two: true,
check-three: true
}
}
|
{
checks: [
{
id: 'check-one',
label: 'Check One',
selected: false
},
{
id: 'check-two',
label: 'Check Two',
selected: true
},
{
id: 'check-three',
label: 'Check Three',
selected: true
}
]
}
|
Radio Group
A radio group works much like a checkbox list, but there is one key distinction: radio buttons that are part of the same group all have to share a name. If they don’t, Angular treats each as a separate form control, producing a new instance for every distinct name it encounters. Given that each radio button is tied to the same TypeScript model value, they all end up holding the same value, and selecting any one of them causes all of them to update. Although the TypeScript model reflects the proper selection, the template model may appear confusing, and this can lead to issues later when validation comes into play.
@Component({
template: `
<label>Radio group</label>
<div>
<input
type="radio"
id="radio-1"
name="formRadioGroup"
[value]="1"
[(ngModel)]="model.radio"
/>
<label for="radio-1">Radio One</label>
</div>
<div>
<input
type="radio"
id="radio-2"
name="formRadioGroup"
[value]="2"
[(ngModel)]="model.radio"
/>
<label for="radio-2">Radio Two</label>
</div>
<div>
<input
type="radio"
id="radio-3"
name="formRadioGroup"
[value]="3"
[(ngModel)]="model.radio"
/>
<label for="radio-3">Radio Three</label>
</div>
`
})
export class AppComponent {
model = {
radio: null
};
}
| Template Form Value | TypeScript Model Value |
|---|---|
{
formRadioGroup: 1
}
|
{
radio: 1
}
|
Forms Controls Example
If you want to experiment with the form controls and watch how your edits propagate to both the template model and the TypeScript model, check out this StackBlitz.
Validators
In template-driven forms, validation relies on adding directives or attributes directly to the form control.
This approach gives the process a native Web platform feel, which is a nice touch.
For a deeper dive into Angular validators, I strongly suggest watching Kara Erickson's presentation Angular Form Validation.
Built-in validators
The FormsModule in Angular provides several directives that mirror the default HTML form validation attributes, with the exception of the min and max validators. A Pull Request covering these has just been merged, so they are expected to arrive in a future Angular release.
<input required />
<input minlength="3" minlength="10" />
<input pattern="/@/" />
Dynamic Validators
To introduce dynamic behavior into validators, you swap the hard-coded attribute value for a property on your component. Any change to that property’s value immediately re-runs the validation logic, passing the updated value along.
Given that validation triggers anew on each change, crafting validators that adapt or react to conditions becomes straightforward.
As an illustration, if you want a field to be mandatory only under certain conditions—say, when another control holds a specific value—you can assign that second control’s value to the required attribute. As long as that assigned value evaluates to true, the field enforces the requirement; otherwise, it is treated as optional. The example below shows a name control whose required status is tied to the checked state of the makeNameRequired checkbox.
@Component({
template: `
<form>
<div class="checkbox-container">
<input
type="checkbox"
id="makeNameRequired"
name="makeNameRequired"
[(ngModel)]="model.makeNameRequired"
/>
<label for="makeNameRequired">Make "name" required</label>
</div>
<label for="text">Name</label>
<input
type="text"
id="text"
name="text"
[(ngModel)]="model.name"
[required]="model.makeNameRequired"
/>
</form>
`,
})
export class AppComponent {
model = {
makeNameRequired: false,
name: '',
}
}
The way you choose to hide or disable a control has consequences beyond the UI: both *ngIf and the disabled attribute strip away every attached validator from that control.
Custom Validators
Those built-in validators serve as a solid foundation and handle everyday cases, but they can't cover every scenario, so building your own validators is often the way to go.
A custom validator is constructed as a dedicated Angular directive that follows the Validator interface(1). To integrate it with the framework, you also have to register that directive in the NG_VALIDATORS provider collection (2).
For illustration, I put together the RequiredCheckboxGroupValidatorDirective.
Its job is simple: in any checkbox group, at least N boxes must be ticked.
import { Directive, Input } from '@angular/core'
import {
AbstractControl,
ValidationErrors,
NG_VALIDATORS,
Validator,
} from '@angular/forms'
@Directive({
selector: '[requiredCheckboxGroup][ngModelGroup]',
// 2: register the custom validator as an Angular Validator
providers: [
{
provide: NG_VALIDATORS,
useExisting: RequiredCheckboxGroupValidatorDirective,
multi: true,
},
],
})
export class RequiredCheckboxGroupValidatorDirective implements Validator {
@Input() requiredCheckboxGroup = 1
// 1: implement the validate method
validate(control: AbstractControl): ValidationErrors | null {
// the value of the control is an object that holds the value of each checkbox
// the value's signature looks like this, `{ 'check-one': false, 'check-two': true }`
const selected = Object.values(control.value).filter(Boolean).length
if (selected < this.requiredCheckboxGroup) {
return {
requiredCheckboxGroup: {
requiredCheckboxes: this.requiredCheckboxGroup,
},
}
}
return null
}
}
It is considered good practice to separate the validate method from the directive, turning it into an independent validator function (ValidatorFn). This makes the validator's internal logic far simpler to test, and additionally opens the door for reusing the same validator within a reactive form.
import { Directive, Input } from '@angular/core'
import {
AbstractControl,
ValidationErrors,
NG_VALIDATORS,
Validator,
} from '@angular/forms'
function requiredCheckboxGroup(requiredCheckboxes: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const selected = Object.values(control.value).filter(Boolean).length
if (selected < requiredCheckboxes) {
return {
requiredCheckboxGroup: { requiredCheckboxes },
}
}
return null
}
}
@Directive({
selector: '[requiredCheckboxGroup][ngModelGroup]',
providers: [
{
provide: NG_VALIDATORS,
useExisting: RequiredCheckboxGroupValidatorDirective,
multi: true,
},
],
})
export class RequiredCheckboxGroupValidatorDirective implements Validator {
@Input() requiredCheckboxGroup = 1
validate(control: AbstractControl): ValidationErrors | null {
return requiredCheckboxGroup(this.requiredCheckboxGroup)(control)
}
}
When the control’s value passes validation, the validate method should return null.
On the other hand, if the value fails validation, it must return an ValidationErrors object containing the error details. These details later serve as the basis for generating user-friendly Validation Messages.
Now that the RequiredCheckboxGroupValidatorDirective is available, we can attach it directly to a control—or, in this instance, to a model group.
<label>Pick a time</label>
<div class="flex space-x-4" ngModelGroup="times" [requiredCheckboxGroup]="1">
<div class="checkbox-container" *ngFor="let time of model.times">
<input
type="checkbox"
[id]="time.label"
[name]="time.label"
[(ngModel)]="time.selected"
/>
<label [for]="time.label">{{ time.label }}</label>
</div>
</div>
The validator in its current form has a flaw. It relies on the requiredCheckboxGroup input property to determine the minimum number of checkboxes that must be selected, but a change to that property does not cause the RequiredCheckboxGroupValidatorDirective to re-evaluate the checkbox group’s validity.
To make the validator react to changes in an input property’s value, the directive needs a few modifications:
- use the
registerOnValidatorChangehook to set up a change listener (1) - add a getter and a setter for the input property (2)
- call the change listener from the setter whenever a fresh value is assigned to the input property (3)
In the upcoming section on Displaying Validation Errors, we'll examine how to turn this object into user-friendly messages.
import { Directive, Input } from '@angular/core'
import {
ValidatorFn,
AbstractControl,
ValidationErrors,
NG_VALIDATORS,
Validator,
} from '@angular/forms'
function requiredCheckboxGroup(requiredCheckboxes: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const selected = Object.values(control.value).filter(Boolean).length
if (selected < requiredCheckboxes) {
return {
requiredCheckboxGroup: { requiredCheckboxes },
}
}
return null
}
}
@Directive({
selector: '[requiredCheckboxGroup][ngModelGroup]',
providers: [
{
provide: NG_VALIDATORS,
useExisting: RequiredCheckboxGroupValidatorDirective,
multi: true,
},
],
})
export class RequiredCheckboxGroupValidatorDirective implements Validator {
private _requiredCheckboxGroup = 1
private _onChange?: () => void
// 2: create a getter and a setter for the input property
@Input()
get requiredCheckboxGroup() {
return this._requiredCheckboxGroup
}
set requiredCheckboxGroup(value: number) {
this._requiredCheckboxGroup = value
// 3: invoke the change handler
if (this._onChange) {
this._onChange()
}
}
validate(control: AbstractControl): ValidationErrors | null {
return requiredCheckboxGroup(this.requiredCheckboxGroup)(control)
}
// 1: register the change handler
registerOnValidatorChange?(fn: () => void): void {
this._onChange = fn
}
}
For yet another illustration, consider a common validator that checks whether two values match, such as verifying that a password field and its confirmation field contain the same entry.
function equalTo(value: any): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value !== value) {
return {
equalTo: value
};
}
return null;
};
}
@Directive({
selector: '[equalTo][ngModel]',
providers: [
{
provide: NG_VALIDATORS,
useExisting: EqualToValidatorDirective,
multi: true
}
]
})
export class EqualToValidatorDirective implements Validator {
private _equalTo: any;
private _onChange?: () => void;
@Input()
get equalTo() {
return this._equalTo;
}
set equalTo(value: any) {
this._equalTo = value;
if (this._onChange) {
this._onChange();
}
}
validate(control: AbstractControl): ValidationErrors | null {
return equalTo(this.equalTo)(control);
}
registerOnValidatorChange?(fn: () => void): void {
this._onChange = fn;
}
}
Async Validators
When a form control's validation depends on an HTTP request, an asynchronous validator becomes necessary.
The structure of an async validator closely mirrors its synchronous counterpart, but there are key differences to note:
- it must be registered with Angular's asynchronous validator token,
NG_ASYNC_VALIDATORS(rather thanNG_VALIDATORS) - it must conform to the
AsyncValidatorinterface (rather thanValidator) - the validate method must return an Observable that emits either
ValidationErrorsornull. A critical detail is that Angular requires this Observable stream to eventually complete.
import { Directive, Inject } from '@angular/core'
import {
NG_ASYNC_VALIDATORS,
AsyncValidator,
AbstractControl,
ValidationErrors,
} from '@angular/forms'
@Directive({
selector: '[uniqueUsername][ngModel]',
providers: [
{
provide: NG_ASYNC_VALIDATORS,
useExisting: UniqueUsernameValidatorDirective,
multi: true,
},
],
})
export class UniqueUsernameValidatorDirective implements AsyncValidator {
constructor(@Inject(UsersService) private usersService: UsersService) {}
validate(
control: AbstractControl,
): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
return this.usersService.isUsernameTaken(control.value as string).pipe(
map((taken) => {
return taken ? { usernameTaken: true } : null
}),
)
}
}
Async validators are applied in the same manner as their synchronous counterparts—simply attach the directive to the input. A typical approach in async validation involves leveraging the ngModelOptions setting so that validation runs when the field loses focus, rather than after each keystroke.
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
[(ngModel)]="model.username"
[ngModelOptions]="{ updateOn: 'blur' }"
uniqueUsername
/>
Nice to knows
- The invocation of an asynchronous validator happens only after every synchronous validator on that control has passed
- During the period an asynchronous validator is running, neither
validnorinvaliddescribes the form control (or the form); instead, the control is assigned thependingstate
Testing Validators
For simple validators, dropping the ValidatorFn method straight into the test may be enough, and this approach can also serve as a sensible entry point for the more involved validators. However, when you want to exercise the revalidation logic, you'll need a test that interacts with the DOM.
One way to set up such a test is to build a host component that serves as the mounting point for the directive through the standard Angular TestBed, which does the job, but... I prefer Angular Testing Library instead, since it cuts down on the boilerplate (for instance, no host component is required and a change detection cycle is triggered automatically), and I also rely on Angular Testing Library for my component testing.
In the two tests that follow, we check that the EqualToValidatorDirective directive, which is defined in Revalidate Custom Validators, behaves as expected. In other words,
- the first test confirms that the control remains valid as long as the input control matches the comparison value,
- whereas the second test confirms that the control's validity is recalculated whenever the comparison value is modified
it('is valid when it has the same value as the comparison value', async () => {
const component = await render(EqualToValidatorDirective, {
template: `<form><input [equalTo]='compareValue' ngModel name="sut" /></form>`,
imports: [FormsModule],
componentProperties: {
compareValue: 'value1'
}
});
const model = component.fixture.debugElement.children[0].injector.get(NgForm);
const input = screen.getByRole('textbox');
userEvent.type(input, 'value2');
expect(model.controls.sut.invalid).toBeTruthy();
expect(model.controls.sut.errors).toEqual({
equalTo: 'value1'
});
userEvent.clear(input);
userEvent.type(input, 'value1');
expect(model.controls.sut.valid).toBeTruthy();
expect(model.controls.sut.errors).toBeNull();
});
it('revalidates on input change', async () => {
const component = await render(EqualToValidatorDirective, {
template: `<form><input [equalTo]='compareValue' ngModel name="sut" /></form>`,
imports: [FormsModule],
componentProperties: {
compareValue: 'value1'
}
});
const model = component.fixture.debugElement.children[0].injector.get(NgForm);
const input = screen.getByRole('textbox');
userEvent.type(input, 'value2');
expect(model.controls.sut.invalid).toBeTruthy();
expect(model.controls.sut.errors).toEqual({
equalTo: 'value1'
});
component.fixture.componentInstance.compareValue = 'value2';
expect(model.controls.sut.valid).toBeTruthy();
expect(model.controls.sut.errors).toBeNull();
});
Validators Example
Every example built in this section has been put together in the StackBlitz below.
Form Errors
This section covers two tasks: turning validation errors into messages that are easy for people to understand, and toggling those messages on and off in the form. To do this, we'll first need to explore the various states that a form control can exist in.
Control States
The status property is the most direct state to check. It can return one of four values: 'VALID', 'INVALID', 'PENDING' (which appears while an async validator is still resolving), or 'DISABLED'. There are also boolean shortcuts for each of these: valid, invalid, pending, and disabled.
Next, we have pristine and its opposite, dirty. These tell us whether the user has modified the control's value. It starts off as pristine, and the flag flips to dirty as soon as the user makes a change. Both of these are boolean values as well.
The final pair of relevant states is untouched and touched. When a user focuses on a control and then moves away—which fires the blur event—the control's state shifts from untouched to touched. Once more, both properties are booleans.
Form groups (NgModelGroup) and forms (NgForm) offer the same set of states. In addition, a form has a submitted property that is set to true when the form's submit event is fired.
CSS Classes
Each control state comes with a matching CSS class.
All you have to do is add the prefix ng- to the state name.
This gives us the classes .ng-valid, .ng-invalid, .ng-pending, .ng-pristine, .ng-dirty, .ng-untouched and .ng-touched. One thing to note is that there isn't a .ng-submitted class for after a form is submitted.
These classes let us create custom styling for the controls in our form.
For instance, you could make the border of an invalid control turn red once it has been touched by applying the styles below.
input.ng-invalid.ng-touched:not(:focus),
select.ng-invalid.ng-touched:not(:focus),
textarea.ng-invalid.ng-touched:not(:focus) {
border-color: red;
}
/* all of the checkboxes inside a required checkbox group */
[requiredcheckboxgroup].ng-invalid.ng-touched input {
border-color: red;
}
Disabled State
To disable a form control, simply place the disabled attribute on the corresponding HTML element.
Once a control is in this state, its status immediately becomes DISABLED.
For a quick check of whether a form element is inactive, the disabled and enabled properties serve as a convenient alternative.
A crucial detail to remember: disabling a control deactivates every one of its validators, and the form model's value for that control will be undefined.
Validation Messages
With a solid grasp of the various form control states, we are ready to build functionality that surfaces validation messages to our users.
Control Errors
Every result produced by the validators is stored in the errors property of the form control.
This property holds an object with key-value pairs: each key identifies a specific validator, and the associated value carries the details of the error it found.
Keep in mind that when the control is valid, its errors property is null.
Take our custom RequiredCheckboxGroupValidatorDirective as an illustration—its error key is requiredCheckboxGroup, and the value holds the count of checkboxes that were mandatory.
To guide users toward correct input, we have to turn these raw error details into clear, readable messages.
The most straightforward attempt might appear as shown below.
Note how the messages stay hidden until the control has been touched, and even then, they are visible only if the control is invalid.
<input type="text" name="name" ngModel required minlength="4" #name="ngModel" />
<div *ngIf="name.invalid && name.touched">
<div *ngIf="name.errors.required">Name is required.</div>
<div *ngIf="name.errors.minlength">
Name must be at least {{ name.errors.minlength.requiredLength }} characters long.
</div>
</div>
While the approach described above works fine for small applications, it does not scale well on bigger codebases, since it comes with a few limitations:
- the implementation is fragile when it comes to modifications, as validation messages need to be added or removed by hand whenever the validation rules of a control change.
- it causes an inconsistent user experience; first, the message wording tends to vary, and second, the conditions under which a message is displayed differ from one developer to the next.
- building or adapting a form takes more time, since all parts have to be wired up manually, and that wiring also has to be tested.
To deliver a better user experience, we have to introduce one or more abstraction layers.
This additional layer carries out two tasks:
- the output of any validator is translated into a validation message
- the layer controls when the message becomes visible
If this layer is built the right way, each of these two responsibilities can be used on its own.
Even though building it takes some effort upfront, it pays off substantially by reducing the time spent developing and maintaining forms later on.
Good news: there are already proven libraries available, such as Angular Material and Error Tailer by ngneat.
To get a closer look at how such a validation mechanism works internally, we will construct the different parts ourselves.
Our approach is inspired by a custom-tailored solution that matches our own pre-existing requirements.
Configuring Validation Messages
We start building reusable validation messages by setting up a place where message templates can be stored.
For that, we create a new InjectionToken called VALIDATION_MESSAGES.
Those stored templates will then be used to generate the final validation messages.
import { InjectionToken } from '@angular/core'
export interface ValidationMessages {
[errorKey: string]: (...errorDetails: any[]) => string
}
export const VALIDATION_MESSAGES = new InjectionToken<ValidationMessages>(
'VALIDATION_MESSAGES',
)
When setting up a message template, each validator receives its template through a factory function.
The VALIDATION_MESSAGES token is where these templates get supplied during Angular module configuration.
As for Angular's built-in validators, my preference is to centralize their message templates within a single module.
import { VALIDATION_MESSAGES } from './validation-message'
@NgModule({
providers: [
{
provide: VALIDATION_MESSAGES,
useValue: {
required: () => 'This field is required',
email: () => 'This field must be a valid email',
minlength: (details: any) =>
`This field must have a minimum length of ${details.requiredLength}`,
maxlength: (details: any) =>
`This field must have a maximum length of ${details.requiredLength}`,
},
multi: true,
},
],
})
export class ValidatorModule {}
For each custom validator, I define its error message template in the same module where the validator itself is registered.
Adhering to the SCAM pattern introduced by Lars Gyrup Brink Nielsen, this approach is both visually tidy and straightforward to work with.
import { VALIDATION_MESSAGES } from './validation-message'
@NgModule({
declarations: [RequiredCheckboxGroupValidatorDirective],
exports: [RequiredCheckboxGroupValidatorDirective],
providers: [
{
provide: VALIDATION_MESSAGES,
useValue: {
requiredCheckboxGroup: (details: any) =>
`This field must have at least ${details.requiredCheckboxes} ${
details.groupName || 'items'
} selected`,
},
multi: true,
},
],
})
export class RequiredCheckboxGroupValidatorModule {}
Validate Pipe
A dedicated Angular Pipe, named ValidatePipe, is what we introduce for turning form control errors into a validation message. The reason a pipe is my preferred approach here is its markup-free nature, letting it be reused across different contexts.
For the message to be built, the validate pipe must tap into the collection of validation message templates. This access is achieved by injecting the VALIDATION_MESSAGES token into the pipe, which brings those templates into scope.
After that, the errors from the form control get handed over to the transform method, where the matching templates are retrieved based on the error key along with the injected messages. If a template exists, it gets executed with the error details passed in.
The way this ValidatePipe pipe is built, it surfaces only a single validation message—the one belonging to the first error encountered, rather than listing them all.
Should an error lack a configured message, a fallback default message steps in.
import { Pipe, PipeTransform, Inject } from '@angular/core'
import { ValidationMessages, VALIDATION_MESSAGES } from './validation-message'
@Pipe({ name: 'validate' })
export class ValidatePipe implements PipeTransform {
// create a key-value pair out of the provided validation messages
readonly validationMessage = this.validationMessages.reduce(
(all, entry) => ({ ...all, ...entry }),
{} as ValidationMessages,
)
constructor(
@Inject(VALIDATION_MESSAGES)
readonly validationMessages: ValidationMessages[],
) {}
transform(validationErrors: ValidationErrors | null) {
// pluck the first error out of the errors
const [error] = Object.entries(validationErrors || {})
if (!error) {
return ''
}
// create the validation message
const [errorKey, errorDetails] = error
const template = this.validationMessage[errorKey]
return template ? template(errorDetails) : 'This field is invalid'
}
}
With the first refactor, the inline messages embedded in the template now give way to the validate pipe, which replaces them directly.
All validation messages across the app now stem from a single source, guaranteeing consistency.
Since everything is centralized, modifications to any message become straightforward down the road.
<input type="text" name="name" ngModel required minlength="4" #name="ngModel" />
<div *ngIf="name.invalid && name.touched">
{{ name.errors | validate }}
</div>
Error Component
To achieve uniform validation messages, we introduce a dedicated component called ControlErrorComponent.
This component handles two distinct responsibilities:
- defining the visual appearance and structure of each message,
- managing the visibility logic of the validation error
Inside the ControlErrorComponent template, errors display only when the associated control is invalid and has been marked as touched by the user.
The actual validation text is produced by piping the errors through the validate pipe, which is detailed in the Validate Pipe section.
import { Component, Input } from '@angular/core'
import { AbstractControl, NgForm } from '@angular/forms'
@Component({
selector: 'app-control-error',
template: `
<div
role="alert"
class="mt-1 text-sm text-red-600"
[hidden]="control.valid || !control.touched"
>
{{ control.errors | validate }}
</div>
`,
styles: [
`
:host {
margin: 0 !important;
}
`,
],
})
export class ControlErrorComponent {
@Input() control: AbstractControl
}
Following the second refactoring pass, the code fragment relies on the ControlErrorComponent component rather than the *ngIf directive seen in the initial version.
By isolating logic into the ControlErrorComponent component, we achieve consistent styling and interaction patterns, which improves the overall user experience. On the engineering side, this approach shields us from upcoming visual overhauls, as adjustments will be required in just a single location.
<input type="text" name="name" ngModel required minlength="4" #name="ngModel" />
<app-control-error [control]="name.control">
{{ name.errors | validate }}
</app-control-error>
Error Directive
To display validation messages, the ControlErrorComponent currently has to be manually added to each form control.
To eliminate this manual step, a new directive named ErrorDirective is being introduced. Its role is to automatically inject the ControlErrorComponent at runtime whenever any form control or form group appears in the template.
import {
Directive,
ComponentFactoryResolver,
AfterViewInit,
ViewContainerRef,
Optional,
} from '@angular/core'
import { NgControl, NgModelGroup } from '@angular/forms'
import { ControlErrorComponent } from './control-error.component'
import { FormFieldDirective } from './form-field.directive'
@Directive({
selector: '[ngModel], [ngModelGroup]',
})
export class ErrorDirective implements AfterViewInit {
constructor(
readonly componentFactoryResolver: ComponentFactoryResolver,
readonly viewContainerRef: ViewContainerRef,
@Optional() readonly ngModel: NgControl,
@Optional() readonly ngModelGroup: NgModelGroup,
@Optional() readonly formFieldDirective: FormFieldDirective,
) {}
ngAfterViewInit() {
setTimeout(() => {
const control = this.ngModel?.control ?? this.ngModelGroup?.control
if (control && !this.formFieldDirective) {
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(
ControlErrorComponent,
)
const errorContainer = this.viewContainerRef.createComponent(
componentFactory,
)
errorContainer.instance.control = control
}
})
}
}
According to the directive's implementation, the error component gets inserted into the DOM right below the input element.
For simple controls, this approach is perfectly adequate. However, when dealing with form groups or checkboxes, it quickly becomes problematic—the validation message can end up rendered in the middle of several related elements.
To correct this issue, we introduce another directive named FormFieldDirective.
The concept mirrors what ErrorDirective does, except that rather than placing the validation message directly after the form control, it appends the message to the end of the container that wraps the form control.
import {
Directive,
ComponentFactoryResolver,
AfterViewInit,
ViewContainerRef,
Optional,
ContentChild,
ElementRef,
} from '@angular/core'
import { NgModel, NgModelGroup } from '@angular/forms'
import { ControlErrorComponent } from './control-error.component'
@Directive({
selector: '[formField]',
})
export class FormFieldDirective implements AfterViewInit {
@ContentChild(NgModel) ngModelChild?: NgModel
@ContentChild(NgModelGroup) ngModelGroupChild?: NgModelGroup
constructor(
private element: ElementRef,
private componentFactoryResolver: ComponentFactoryResolver,
private viewContainerRef: ViewContainerRef,
@Optional() private ngModelGroup: NgModelGroup,
) {}
ngAfterViewInit() {
setTimeout(() => {
const control =
this.ngModelGroup?.control ??
this.ngModelChild?.control ??
this.ngModelGroupChild?.control
if (control) {
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(
ControlErrorComponent,
)
this.viewContainerRef.clear()
const errorContainer = this.viewContainerRef.createComponent(
componentFactory,
)
const host = this.element.nativeElement as HTMLElement
host.style.flexWrap = 'wrap'
host.appendChild(errorContainer.location.nativeElement)
errorContainer.instance.control = control
}
})
}
}
Adding the formField attribute to the form control container is required for utilizing the form field directive.
Alternatively, the ControlErrorComponent remains a valid option, but in my view, this approach aligns more closely with the behavior of the ErrorDirective.
<div class="checkbox-container" formField>
<input type="checkbox" id="tac" name="tac" ngModel required />
<label for="tac">I agree with the terms and conditions</label>
</div>
With the refactor now complete, no validation message logic remains in our form components at all.
<input type="text" name="name" ngModel required minlength="4" />
Form Helpers
Up to now, we've only been flagging invalid controls and displaying validation messages solely for fields that the user has already interacted with. However, users also anticipate feedback upon form submission.
There are a couple of ways to bring this about.
One approach involves introducing another check in the Error Component, leveraging the submitted flag attached to the form to see if it has been sent. To color invalid inputs with a red border in this scenario, a submitted class is also necessary on the form element. Thus, a built-in .ng-submitted style would be handy — unfortunately, that's not (currently?) available.
The alternative is to mark every control as touched the moment the form is submitted. You can achieve this by calling the form's markAllAsTouched method.
Errors Example
Check out the StackBlitz linked below to see a live demonstration of control states and validation messages in action.
Dynamic Forms
I was pleasantly surprised to find that building dynamic (and even nested) forms with template-driven forms is quite easy. This stands out to me because it was a persistent headache when I worked with reactive forms.
Consider the sample below, where we set up a team, then dynamically add, remove, or reorder its members. To picture it clearly, the form for the team appears as follows.
The following example has been simplified to focus only on the essentials. The core features are emphasized and explained in detail further down.
import { Component, Output, ViewChild, EventEmitter } from '@angular/core';
import { NgForm } from '@angular/forms';
@Component({
template: `
<form #form="ngForm" (submit)="submit()">
<!-- iterate over all members of the model -->
<fieldset
*ngFor="let member of model.members;"
>
<label [for]="'first-name-' + member.id">First name</label>
<!-- input elements have a unique id and name -->
<input
type="text"
[id]="'first-name-' + member.id"
[name]="'first-name-' + member.id"
[(ngModel)]="member.firstName"
required
/>
<label [for]="'last-name-' + member.id">Last name</label>
<input
type="text"
[id]="'last-name-' + member.id"
[name]="'last-name-' + member.id"
[(ngModel)]="member.lastName"
required
/>
<button
type="button"
(click)="removeClicked(member.id)"
[hidden]="model.members.length === 1"
>
Remove member
</button>
</fieldset>
<button>Submit Form</button>
<button
type="button"
(click)="addClicked()"
[hidden]="model.members.length > 5"
>
Add team member
</button>
</form>
`
})
export class DynamicComponentFlat {
@Output() submitEmitter = new EventEmitter<any>();
@ViewChild(NgForm) form!: NgForm;
model: Team = {
members: [
{
id: Date.now().toString(),
firstName: 'Emily',
lastName: 'Earnshaw',
}
]
};
addClicked() {
// mutate the model by adding a new member
this.model.members.push({
id: Date.now().toString(),
lastName: '',
firstName: '',
});
}
removeClicked(id: string) {
// mutate the model by removing the member by id
this.model.members = this.model.members.filter((m) => m.id !== id);
}
submit() {
if (this.form.valid) {
this.submitEmitter.emit(this.model);
} else {
this.form.form.markAllAsTouched();
}
}
}
In the earlier section, Creating a Form, we saw that the DOM form mirrors the TypeScript model.
This means that looping over the model’s collection to build a nested form works seamlessly, with two-way binding connecting each item’s properties to its form control.
Any change to the collection — like model.members in that example — is instantly reflected in the DOM.
You can alter the collection by calling any of the Array prototype methods or assigning a new value to the variable.
Nested Forms
The example template relies on a flat form model, but you could equally restructure it as a nested form model. While not mandatory, this approach brings certain benefits that can come in handy in specific situations.
One benefit stems from the fact that a nested form is a FormGroup, unlocking its full feature set. For instance, the reset method clears every control within the group, and this reset also propagates back to the TypeScript model.
Another benefit is easier validator attachment to the form group. Although a flat structure also permits validators, wiring them up takes considerably more work.
Moving from a flat to a nested layout involves placing the form controls inside a parent element tagged with the ngModelGroup directive.
In the sample below, each team member’s id doubles as the key for their respective form group, keeping them distinct. Additionally, an outer members group wraps all team members, allowing a single reset call to clear the entire roster.
@Component({
template: `
<form #form="ngForm" (submit)="submit()">
<!-- technically this is not needed, but it's added here to showcase the reset -->
<ng-container ngModelGroup="members">
<!-- iterate over all members of the model -->
<fieldset
*ngFor="let member of model.members;"
[ngModelGroup]="member.id"
>
<label for="first-name">First name</label>
<!-- input elements have a unique id but
the name is the same because it belongs to another group -->
<input
type="text"
id="first-name"
name="first-name"
[(ngModel)]="member.firstName"
required
/>
<label for="last-name">Last name</label>
<input
type="text"
id="last-name"
name="last-name"
[(ngModel)]="member.lastName"
required
/>
<button
type="button"
(click)="removeClicked(member.id)"
[hidden]="model.members.length === 1"
>
Remove member
</button>
<button
type="button"
(click)="memberResetClicked(member.id)"
>
Reset
</button>
</fieldset>
</ng-container>
<button>Submit Form</button>
<button
type="button"
(click)="addClicked()"
[hidden]="model.members.length > 5"
>
Add team member
</button>
<button
type="button"
(click)="teamResetClicked()"
>
Reset Team
</button>
<button
type="button"
(click)="formResetClicked()"
>
Reset Form
</button>
</form>
`,
})
export class DynamicComponentGrouped {
@Output() submitEmitter = new EventEmitter<any>();
@ViewChild(NgForm) form!: NgForm;
model: Team = {
members: [
{
id: Date.now().toString(),
firstName: 'Emily',
lastName: 'Earnshaw',
},
],
};
addClicked() {
this.model.members.push({
id: Date.now().toString(),
lastName: '',
firstName: '',
});
}
removeClicked(id: string) {
this.model.members = this.model.members.filter((m) => m.id !== id);
}
teamResetClicked() {
this.teamMembersControl.reset();
}
memberResetClicked(id: string) {
this.teamMembersControl.get(id)?.reset();
}
formResetClicked() {
this.model = {
members: [],
};
}
get teamMembersControl() {
return this.form.form.get('members') as FormGroup;
}
submit() {
if (this.form.valid) {
this.submitEmitter.emit(this.model);
} else {
this.form.form.markAllAsTouched();
}
}
}
Dynamic Nested Forms Example
You'll find the complete implementation in this StackBlitz.
Along with that, the project includes functionality for reordering team members and additional validation checks.
Sub-Form Components
Up to this point, all our examples show a form living inside a single component.
But this structure isn't always ideal. You may want to delegate some responsibilities by breaking the component apart—either when it grows too large to manage or when you need to reuse a specific section of the form elsewhere.
Sub-form components are the answer, and you have two distinct ways to set them up.
The most comprehensive talk on advanced form techniques is Angular Forms, delivered by Kara Erickson.
Injecting the Control Container
The quickest and least complex approach involves passing the parent's ControlContainer into the child component. Just as the term ControlContainer suggests, this object acts as a grouping container for several form controls. The classes NgForm and NgModelGroup both fall under this category.
To grant the sub-form access to the parent's form, you need to expose the control container through the view provider field within the sub-form component's decorator.
@Component({
template: '...',
viewProviders: [
{
provide: ControlContainer,
// when the sub-form is a child of a form
useExisting: NgForm,
// when the sub-form is a child of a model group
useExisting: NgModelGroup
}
]
})
export class SubFormComponent {}
When a sub-form is nested under either a form or a model group, the correct parent instance must be selected. This dependency on the parent container type limits the sub-form component's reusability, since it's unclear which parent it will be attached to. Ideally, the sub-form should function seamlessly in both scenarios.
A sturdier approach is to explicitly supply the appropriate control container (obviously!).
For this purpose, I borrowed this code snippet from Ward Bell.
The formViewProvider consistently returns the correct parent instance: it attempts to return the NgModelGroup first, and if that's unavailable, it defaults to a NgForm.
export const formViewProvider: Provider = {
provide: ControlContainer,
useFactory: _formViewProviderFactory,
deps: [
[new Optional(), NgForm],
[new Optional(), NgModelGroup]
]
};
export function _formViewProviderFactory(
ngForm: NgForm, ngModelGroup: NgModelGroup
) {
return ngModelGroup || ngForm || null;
}
This is what the sub-form component relies on.
@Component({
template: '...',
viewProviders: [formViewProvider]
})
export class SubFormComponent {}
After the control container has been injected, the form can be further constructed within the sub-component.
Consider the refactored team form as an illustration.
Here, each team member has been moved into its own dedicated sub-component.
@Component({
selector: 'app-team',
template: `
<form (submit)="submit()">
<label for="team-name">Team name</label>
<input
type="text"
id="team-name"
name="team-name"
[(ngModel)]="model.name"
required
/>
<app-team-members
[members]="model.members"
(add)="addTeamMember()"
(remove)="removeTeamMember($event)"
>
</app-team-members>
</form>
`,
})
export class TeamComponent {
@Output() submitEmitter = new EventEmitter<any>();
@ViewChild(NgForm) form!: NgForm;
model: Team = {
name: '',
members: [
{
id: Date.now().toString(),
firstName: 'Emily',
lastName: 'Earnshaw',
},
],
};
addTeamMember() {
this.model.members.push({
id: Date.now().toString(),
lastName: '',
firstName: '',
});
}
removeTeamMember(memberId: string) {
this.model.members = this.model.members.filter((m) => m.id !== memberId);
}
submit() {
if (this.form.valid) {
this.submitEmitter.emit(this.model);
} else {
this.form.form.markAllAsTouched();
}
}
}
The structure of the team member component is shown below.
Notice that, apart from injecting the control container, this approach leaves the way (sub-) forms are constructed completely untouched.
@Component({
selector: 'app-team-members',
viewProviders: [formViewProvider],
template: `
<fieldset
*ngFor="let member of members"
[ngModelGroup]="member.id"
#memberForm="ngModelGroup"
>
<label [for]="'first-name-' + member.id">First name</label>
<input
type="text"
[id]="'first-name-' + member.id"
name="first-name"
[(ngModel)]="member.firstName"
required
/>
<label [for]="'last-name-' + member.id">Last name</label>
<input
type="text"
[id]="'last-name-' + member.id"
name="last-name"
[(ngModel)]="member.lastName"
required
/>
<button
type="button"
(click)="remove.emit(member.id)"
[hidden]="members.length === 1"
>
Remove member
</button>
<button
type="button"
(click)="memberResetClicked(memberForm)"
>
Reset
</button>
</fieldset>
<button>Submit Form</button>
<button
type="button"
(click)="add.emit()"
[hidden]="members.length > 5"
>
Add team member
</button>
`,
})
export class TeamMemberComponent {
@Input() members: TeamMember[] = [];
@Output() add = new EventEmitter<void>();
@Output() remove = new EventEmitter<string>();
memberResetClicked(memberForm: NgModelGroup) {
memberForm.reset();
}
}
Control Value Accessor
The control container approach is easy to grasp, yet it falls short of the flexibility offered by a Control Value Accessor (or CVA for short).
With the control container, your sub-form is hard-wired to the template-driven forms paradigm. That's fine when your whole team sticks to template-driven forms, but it turns into a hurdle once your components are reused elsewhere—for instance, by another team relying on reactive forms.
Control Value Accessors add one more advantage: they can be crafted as Angular directives, not just components.
Still, depending on your context, the added intricacy of a CVA may not justify these perks.
To build one, you need to implement the ControlValueAccessor interface.
I'm not going to unpack every detail here, but this is what a basic typeahead might look like.
For the Control Value Accessors to be picked up, you must supply the component or directive through the NG_VALUE_ACCESSOR multi-token.
Then, the component or directive handles the writeValue, registerOnChange, registerOnTouched, and optionally setDisabledState methods from the ControlValueAccessor interface, linking the Angular API to a DOM element.
@Directive({
selector: 'input[type=text][ngModel][typeaheadItems]',
host: {
'(input)': 'inputInputted($event)',
'(focus)': 'inputFocussed($event)',
'(blur)': 'inputBlurred($event)',
},
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: TypeaheadDirective,
},
],
})
export class TypeaheadDirective implements ControlValueAccessor {
@Input() typeaheadItems?: { value: any; label: string }[];
selectedItem: { value: any; label: string } | null = null;
onChange = (_: any) => {};
onTouched = () => {};
factory = this.componentFactoryResolver.resolveComponentFactory(
TypeaheadItemsComponent
);
menuItemsRef?: ComponentRef<TypeaheadItemsComponent>;
constructor(
readonly elementRef: ElementRef,
readonly componentFactoryResolver: ComponentFactoryResolver,
readonly viewContainerRef: ViewContainerRef
) {}
@HostListener('document:click', ['$event'])
documentClicked(event: MouseEvent) {
if (event.target !== this.elementRef.nativeElement) {
this.menuItemsRef?.instance.itemSelected.unsubscribe();
this.menuItemsRef?.destroy();
if (!this.selectedItem) {
this.writeValue(null);
}
}
}
inputInputted(event: Event) {
this.populateItems((event.target as HTMLInputElement).value);
this.onChange(null);
this.selectedItem = null;
}
inputFocussed(event: Event) {
this.menuItemsRef = this.viewContainerRef.createComponent(this.factory);
this.populateItems((event.target as HTMLInputElement).value);
this.menuItemsRef.instance.itemSelected.subscribe({
next: (value: { value: any; label: string }) => this.itemClicked(value),
});
}
inputBlurred() {
this.onTouched();
}
itemClicked(item: { value: any; label: string }) {
this.onChange(item.value);
this.writeValue(item);
}
writeValue(obj: any): void {
// update the value of the input element when the model's value changes
this.elementRef.nativeElement.value = obj && obj.label ? obj.label : '';
this.selectedItem = obj;
}
registerOnChange(fn: any): void {
// register the `onChange` hook to update the value of the model
this.onChange = fn;
}
registerOnTouched(fn: any): void {
// register the `onTouched` hook to mark when the element has been touched
this.onTouched = fn;
}
setDisabledState?(isDisabled: boolean): void {
// disable the native element when the form or control is disabled
this.elementRef.nativeElement.disabled = isDisabled;
}
private populateItems(value: string) {
if (this.menuItemsRef) {
this.menuItemsRef.instance.data =
this.typeaheadItems?.filter((v) => v.label.includes(value)) || [];
}
}
}
From here on, the Control Value Accessor behaves just like any standard Angular form control.
In other words, attaching the ngModel attribute to it is all you need to do.
<label for="team-level">Team level</label>
<!-- if the CVA is a directive -->
<input
type="text"
id="team-level"
name="team-level"
required
[(ngModel)]="model.level"
[typeaheadItems]="levels"
/>
<!-- if the CVA is a component -->
<app-typeahead
name="team-level"
required
[(ngModel)]="model.level"
[typeaheadItems]="levels"
></app-typeahead>
Sub-Form Components Example
Unsurprisingly, a StackBlitz demo accompanies this section too.
All the snippets from this article live in the GitHub repo. Keep in mind that the content here is still evolving. Over the coming weeks, I expect to add material on validation, nested forms, template-driven form testing, control value accessors, and dynamic forms. Want a specific topic covered or have feedback? Ping me on Twitter or file an issue via GitHub Issues.
Connect on Twitter at @tim_deschryver | Join the Newsletter | First published on timdeschryver.dev.



