Introduction
In this blog post, I describe how to use Angular 2-way data binding and typed reactive forms to build complex form. The original idea is from Vue 3 2-way model where the child components emit form values to the parent component. When the parent component clicks the submit button, the submit event calls a function to construct a JSON object and send the payload to the server side to process.
After reading the official documentation, I can do the same thing using Angular 2-way data binding and typed reactive form. Therefore, I would like to describe my approach in this blog post.
Create typed reactive form in child components
In this demo, AppComponent
has two child components, PersonFormComponent
and AddressFormComponent
. Both components respectively contain a typed reactive form that can monitor value change and emit the form group to AppComponent
subsequently.
The typed reactive form of PersonFormComponent
is consisted of firstName and lastName fields. Similarly, the form of AddressFormComponent
is consisted of streetOne, streetTwo, city and country fields.
The form fields of PersonFormComponent
and AddressFormComponent
are very similar; therefore, I refactored the logic and HTML template into FormFieldComponet
. FormFieldComponent
has a label, a text input field and a span element to display error message.
// config.interface.ts
export interface Config {
label: string,
errors?: { key: string; message: string }[]
}
// form-field.component.ts
// ... import statements ...
@Component({
selector: 'app-form-field',
standalone: true,
imports: [NgFor, NgIf, ReactiveFormsModule],
template: `
<div [formGroup]="form">
<label for="{{ key }}">
<span>{{ config['label'] || 'Label' }}</span>
<input [id]="key" [name]="key" [formControlName]="key" />
<ng-container *ngFor="let error of config['errors'] || []">
<span class="error" *ngIf="form.controls[key]?.errors?.[error.key] && form.controls[key]?.dirty">
{{ error.message }}
</span>
</ng-container>
</label>
</div>
`,
})
export class FormFieldComponent {
form = inject(FormGroupDirective).form;
@Input({ required: true })
key!: string;
@Input({ required: true })
config!: Config;
}
I injected FormGroupDirective
to obtain the enclosing form (the form in PersonFormComponent
or AddressFormComponent
). inject(FormGroupDirective).form
returned an instance of FormGroup
that I assigned to formGroup input. The component read the configuration to display label and error message, and bind key to formControlName.
Setup of PersonFormComponent
// user-form.interface.ts
export interface UserForm {
firstName: string;
lastName: string;
}
// user-form.config.ts
import { Config } from "../../form-field/interfaces/config.interface";
export const USER_FORM_CONFIGS: Record<string, Config> = {
firstName: {
label: "First Name: ",
errors: [{ key: 'required', message: 'First name is required' }],
},
lastName: {
label: "Last Name: ",
errors: [{ key: 'required', message: 'Last name is required' }],
},
}
USER_FORM_CONFIGS
described the configurations of first name and last name fields. The label of first name field is "First Name: " and it displayed required error message. The label of last name field is "Last Name: " and it also displayed required error message.
// person-form.component.ts
// ... import statements ...
@Component({
selector: 'app-person-form',
standalone: true,
imports: [FormFieldComponent, NgFor, ReactiveFormsModule],
template: `
<h3>Person Form</h3>
<div class="form" [formGroup]="form">
<app-form-field *ngFor="let key of keys; trackBy: trackFunction" [key]="key" [config]="configs[key]" />
</div>
`
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PersonFormComponent {
@Input({ required: true })
userForm!: UserForm;
@Output()
userFormChange = new EventEmitter<UserForm>();
@Output()
isPersonFormValid = new EventEmitter<boolean>();
form = new FormGroup({
firstName: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
lastName: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
})
configs = USER_FORM_CONFIGS;
keys = Object.keys(this.configs);
constructor() {
this.form.valueChanges
.pipe(takeUntilDestroyed())
.subscribe((values) => {
this.userForm = {
firstName: values.firstName || '',
lastName: values.lastName || '',
};
this.userFormChange.emit(this.userForm);
this.isPersonFormValid.emit(this.form.valid);
});
}
trackFunction(index: number, key: string) {
return key;
}
}
Setup of AddressFormComponent
// address-form.interface.ts
export interface AddressForm {
streetOne: string;
streetTwo: string;
city: string;
country: string;
}
// address-form.config.ts
import { Config } from "../../form-field/interfaces/config.interface";
export const ADDRESS_FORM_CONFIGS: Record<string, Config> = {
streetOne: {
label: "Street 1: ",
errors: [{ key: 'required', message: 'Street 1 is required' }],
},
streetTwo: {
label: "Street 2: ",
errors: [{ key: 'required', message: 'Street 2 is required' }],
},
city: {
label: "City: ",
errors: [
{ key: 'required', message: 'City is required' },
{ key: 'minlength', message: 'City is at least 3 characters long' }
],
},
country: {
label: "Country: ",
errors: [
{ key: 'required', message: 'Country is required' },
{ key: 'minlength', message: 'Country is at least 3 characters long' }
],
}
}
ADDRESS_FORM_CONFIGS
described the configurations of street 1, street 2, city and country fields. Street 1, street 2, city and country are required fields. City and country fields are at least 3 characters long.
// address-form.components.ts
// ... import statements
@Component({
selector: 'app-address-form',
standalone: true,
imports: [FormFieldComponent, NgFor, ReactiveFormsModule],
template: `
<h3>Address Form</h3>
<div class="form" [formGroup]="form">
<app-form-field *ngFor="let key of keys; trackBy trackFunction" [key]="key" [config]="configs[key]" />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddressFormComponent {
@Input({ required: true })
addressForm!: AddressForm;
@Output()
addressFormChange = new EventEmitter<AddressForm>();
@Output()
isAddressFormValid = new EventEmitter<boolean>();
configs = ADDRESS_FORM_CONFIGS;
keys = Object.keys(this.configs);
form = new FormGroup({
streetOne: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
streetTwo: new FormControl('', { nonNullable: true, validators: [Validators.required]}),
city: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(3)] }),
country: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(3)] }),
});
constructor() {
this.form.valueChanges
.pipe(takeUntilDestroyed())
.subscribe((values) => {
this.addressForm = {
streetOne: values.streetOne || '',
streetTwo: values.streetTwo || '',
city: values.city || '',
country: values.country || '',
};
this.addressFormChange.emit(this.addressForm);
this.isAddressFormValid.emit(this.form.valid);
});
}
trackFunction(index: number, key: string) {
return key;
}
}
Explain Angular 2-way data binding
For 2-way data binding to work, the component has Input
and Output
decorator. If the name of the input is A, the name of the output is AChange.
In PersonFormComponent
, the pair is userForm
and userFormChange
.
@Input({ required: true })
userForm!: UserForm;
@Output()
userFormChange = new EventEmitter<UserForm>();
@Output()
isPersonFormValid = new EventEmitter<boolean>();
In the constructor, I monitored valueChange
and subscribed to the Observable to update userForm
and emit it to useFormChange
, and emit form validity to isPersonFormValid
that was a boolean.
constructor() {
this.form.valueChanges
.pipe(takeUntilDestroyed())
.subscribe((values) => {
this.userForm = {
firstName: values.firstName || '',
lastName: values.lastName || '',
};
this.userFormChange.emit(this.userForm);
this.isPersonFormValid.emit(this.form.valid);
});
}
I repeated the same procedure in AddressFormComponent
to share the form values with AppComponent
. The pair is addressForm
and addressFormChange
.
@Input({ required: true })
addressForm!: AddressForm;
@Output()
addressFormChange = new EventEmitter<AddressForm>();
@Output()
isAddressFormValid = new EventEmitter<boolean>();
constructor() {
this.form.valueChanges
.pipe(takeUntilDestroyed())
.subscribe((values) => {
this.addressForm = {
streetOne: values.streetOne || '',
streetTwo: values.streetTwo || '',
city: values.city || '',
country: values.country || '',
};
this.addressFormChange.emit(this.addressForm);
this.isAddressFormValid.emit(this.form.valid);
});
}
In the constructor, I monitored valueChange
and subscribed to the Observable to update addressForm
and emit it to addressFormChange
, and emit form validity to isAddressFormValid
that was a boolean.
See 2-way data binding in action in AppComponent
In AppComponent
, I defined models and boolean members to bind to PersonFormComponent
and AddressFormComponent
.
When user typed into input fields of PersonFormComponent
, Angular updated userForm
in AppComponent
. Moreover, isChildPersonFormValid
stored the valid value of the person form.
// main.ts
userForm: UserForm = {
firstName: '',
lastName: '',
};
isChildPersonFormValid = false;
<app-person-form
[(userForm)]="userForm"
(isPersonFormValid)="isChildPersonFormValid = $event"
/>
When user typed into input fields of AddressFormComponent
, Angular updated AddressForm
in AppComponent
. Moreover, isChildAddressFormValid
stored the valid value of the address form.
// main.ts
addressForm: AddressForm = {
streetOne: '',
streetTwo: '',
city: '',
country: '',
};
isChildAddressFormValid = false;
<app-address-form
[(addressForm)]="addressForm"
(isAddressFormValid)="isChildAddressFormValid = $event"
/>
When user clicked form submit button in AppComponent
, it triggered handleSubmit
method to construct the JSON payload and display the payload in alert function.
// main.ts
// ... import statements ...
@Component({
selector: 'my-app',
standalone: true,
imports: [JsonPipe,
PersonFormComponent, AddressFormComponent, FormsModule],
template: `
<h2>2-way component binding to build complex form</h2>
<form (ngSubmit)="handleSubmit()">
<app-person-form
[(userForm)]="userForm"
(isPersonFormValid)="isChildPersonFormValid = $event"
/>
<app-address-form
[(addressForm)]="addressForm"
(isAddressFormValid)="isChildAddressFormValid = $event"
/>
<button type="submit" [disabled]="!isChildPersonFormValid || !isChildAddressFormValid">Submit</button>
</form>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
userForm: UserForm = {
firstName: '',
lastName: '',
};
addressForm: AddressForm = {
streetOne: '',
streetTwo: '',
city: '',
country: '',
};
isChildPersonFormValid = false;
isChildAddressFormValid = false;
handleSubmit() {
console.log('handleSubmit called');
const formData = {
...this.userForm,
...this.addressForm,
}
alert(JSON.stringify(formData));
}
}
The following Stackblitz repo shows the final results:
This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.
Resources:
- Github Repo: https://github.com/railsstudent/ng-component-2-way-binding-form-submit
- Stackblitz: https://stackblitz.com/edit/stackblitz-starters-qcczkv?file=src%2Fmain.ts
- Angular 2-way binding: https://angular.io/guide/two-way-binding