Angular 2-way data binding to build complex form
Tags: angular, tutorial, webdev
Published: 2023-10-10 16:05:13

##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.

```typescript
// config.interface.ts

export interface Config {
  label: string,
  errors?: { key: string; message: string }[]
}
```

```typescript
// 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

```typescript
//  user-form.interface.ts

export interface UserForm {
    firstName: string;
    lastName: string;
}
```

```typescript
// 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.

```typescript
// 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

```typescript
//  address-form.interface.ts

export interface AddressForm {
    streetOne: string;
    streetTwo: string;
    city: string;
    country: string;
}
```

```typescript
// 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. 

```typescript
// 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`.

```typescript
@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.

```typescript
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`.

```typescript
@Input({ required: true })
addressForm!: AddressForm;

@Output()
addressFormChange = new EventEmitter<AddressForm>();

@Output()
isAddressFormValid = new EventEmitter<boolean>();
```

```typescript
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.

```typescript
// 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.

```typescript
// 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.

```typescript
// 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:

{%embed https://stackblitz.com/edit/stackblitz-starters-qcczkv?file=src%2Fmain.ts %}

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