Angular 2-way data binding to build complex form

Connie Leung - Oct 10 '23 - - Dev Community

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 }[]
}
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode

I injected FormGroupDirective to obtain the enclosing form (the form in PersonFormComponent or AddressFormComponent). inject(FormGroupDirective).form returned an instance of FormGroupthat 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;
}
Enter fullscreen mode Exit fullscreen mode
// 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' }],
  },
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Setup of AddressFormComponent

//  address-form.interface.ts

export interface AddressForm {
    streetOne: string;
    streetTwo: string;
    city: string;
    country: string;
}
Enter fullscreen mode Exit fullscreen mode
// 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' }
    ],
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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);
      });
  }
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode
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);
      });
}
Enter fullscreen mode Exit fullscreen mode

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"
/>
Enter fullscreen mode Exit fullscreen mode

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"
/>
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .