DEV Community

Cover image for InDepth Guide for Creating Nested and Reusable Forms
Dharmen Shah
Dharmen Shah

Posted on • Originally published at indepth.dev

InDepth Guide for Creating Nested and Reusable Forms

In this tutorial you will learn how to create and use form-groups. And we will also learn how to achieve complete reusability.

Introduction

In web applications, we can have large forms, like account registration form, profile creation form, credit card form, address form, etc. These large forms generally have a set of fields which are repeated in them.

For example, address fields are generally available in all the registration forms. In this tutorial, we will learn how to wrap such fields in a reusable form, so that it can be used as nested form any other form.

In this tutorial, we will discuss 4 approaches to create a form and we will also look at pros and cons for each:

  1. How to create and use form-group
  2. How to create nested form-groups
  3. How to use child component with nested form-group
  4. How to create reusable form with Composite ControlValueAccessor

How to create and use FormGroup

Forms typically contain several related controls. Reactive forms provide two ways of grouping multiple related controls into a single input form: FormGroup and FormArray. For this tutorial, we will focus on FormGroup.

Just as a form control instance gives you control over a single input field, a form group instance tracks the form state of a group of form control instances (for example, a form). Each control in a form group instance is tracked by name when creating the form group.

We will take an example of Profile form, which will have input fields for name, email, address lines, city, state, zip-code and country.

profile form

The form-group for the same looks like below:

// src/app/simple-form-group/simple-form-group.component.ts

@Component({
  selector: "app-simple-form-group",
  templateUrl: "simple-form-group.component.html",
})
export class SimpleFormGroupComponent {
  profileFormGroup = new FormGroup<SimpleProfileForm>({
    name: new FormControl("", {
      validators: [Validators.required],
      nonNullable: true,
    }),
    email: new FormControl("", {
      validators: [Validators.email, Validators.required],
      nonNullable: true,
    }),
    line1: new FormControl("", {
      validators: [Validators.required],
      nonNullable: true,
    }),
    // other address form controls
  });

  readonly COUNTRIES = COUNTRIES;
  constructor() {}

  get nameFC() {
    return this.profileFormGroup.get("name");
  }
  get emailFC() {
    return this.profileFormGroup.get("email");
  }
  get line1FC() {
    return this.profileFormGroup.get("line1");
  }
  // other form-control getters
}

export type SimpleProfileForm = {
  name: FormControl<string>;
  email: FormControl<string>;
  line1: FormControl<string>;
  line2?: FormControl<string | null>;
  zipCode: FormControl<string>;
  city: FormControl<string>;
  state: FormControl<string>;
  country: FormControl<string>;
};
Enter fullscreen mode Exit fullscreen mode

Notice that we are also taking advantage of the strictly typed forms feature, which is introduced in Angular 14.

In the above code, we are simply creating a FormGroup with all the fields as FormControl. The HTML template code can look like below:

<!-- src/app/simple-form-group/simple-form-group.component.html -->

<form [formGroup]="profileFormGroup">
  <h2>Basic Information</h2>
  <div>
    <label for="name">Name*</label>
    <input
      type="name"
      formControlName="name"
      id="name"
      required
      [ngClass]="{
        'is-invalid': nameFC?.invalid && (nameFC?.touched || nameFC?.dirty)
      }"
    />
    <div class="invalid-feedback">Name is required</div>
  </div>
  <!-- other basic information fields -->
  <div>
    <h2>Address</h2>
    <div>
      <label for="line1">Line 1*</label>
      <input
        [id]="'line1'"
        [name]="'line1'"
        autocomplete="address-line1"
        formControlName="line1"
        required
        type="text"
        [ngClass]="{
          'is-invalid': line1FC?.invalid && (line1FC?.touched || line1FC?.dirty)
        }"
      />
      <div class="invalid-feedback">Address line 1 is required</div>
    </div>
    <!-- other address fields -->
  </div>
  <button type="submit" [disabled]="profileFormGroup.invalid">
    Submit form
  </button>
</form>
Enter fullscreen mode Exit fullscreen mode

The formControlName input provided by the FormControlName directive binds each individual input to the form control defined in FormGroup. The form controls communicate with their respective elements. They also communicate changes to the form group instance, which provides the source of truth for the model value.

Pros

  1. Simple and straight-forward template and form-group

Cons

  1. Difficult to create and maintain complex form models
  2. Logical segregation is missing between set of fields

How to create nested form-groups

When building complex forms, managing the different areas of information is easier in smaller sections. Using a nested form group instance lets you break large form groups into smaller, more manageable ones.

Let’s group address related fields in a nested group:

// src/app/nested-from-group/nested-from-group.component.ts

@Component({
  selector: "app-nested-form-group",
  templateUrl: "nested-form-group.component.html",
})
export class NestedFormGroupComponent {
  profileFormGroup = new FormGroup<ProfileForm>({
    name: new FormControl("", {
      validators: [Validators.required],
      nonNullable: true,
    }),
    email: new FormControl("", {
      validators: [Validators.email, Validators.required],
      nonNullable: true,
    }),
    address: new FormGroup<AddressForm>({
      line1: new FormControl("", {
        validators: [Validators.required],
        nonNullable: true,
      }),
      // other address fields...
    }),
  });
}

type ProfileForm = {
  name: FormControl<string>;
  email: FormControl<string>;
  address: FormGroup<AddressForm>;
};

type AddressForm = {
  line1: FormControl<string>;
  line2?: FormControl<string | null>;
  zipCode: FormControl<string>;
  city: FormControl<string>;
  state: FormControl<string>;
  country: FormControl<string>;
};
Enter fullscreen mode Exit fullscreen mode

Notice that now in profileForm address related fields are grouped in address element. We also created a AddressForm type to get better type safety, and better autocomplete in IDEs. Let’s group group nested form in the template:

<!-- src/app/nested-from-group/nested-form-group.component.html -->

<form [formGroup]="profileFormGroup">
  <h2>Basic Information</h2>
  <!-- basic information fields -->
  <!-- notice the usage of formGroupName below -->
  <div formGroupName="address">
    <h2>Address</h2>
    <div>
      <label for="line1">Line 1*</label>
      <input
        [id]="'line1'"
        [name]="'line1'"
        autocomplete="address-line1"
        formControlName="line1"
        required
        type="text"
        [ngClass]="{
          'is-invalid': line1FC?.invalid && (line1FC?.touched || line1FC?.dirty)
        }"
      />
      <div class="invalid-feedback">Address line 1 is required</div>
    </div>
    <!-- other address related fields -->
  </div>
  <button type="submit" [disabled]="profileFormGroup.invalid">
    Submit form
  </button>
</form>
Enter fullscreen mode Exit fullscreen mode

We added the formGroupName directive to indicate that children form controls are part of address form-group.

Pros

  1. Easy to create and maintain complex form models
  2. Logical segregation is present between set of fields

Cons

  1. Larger template due to more fields and complex form

How to use child component with nested form-group

In the previous section, we achieved logical separation in class, but our template is still complex. We will create another component to handle template complexity.

// src/app/nested-form-group-child/address-form/address-form.component.ts

@Component({
  selector: "app-address-form",
  templateUrl: "address-form.component.html",
})
export class AddressFormComponent {
  @Input("formGroup") addressFormGroup!: FormGroup;

  get line1FC() {
    return this.addressFormGroup.get("line1");
  }
  // other form-control getters...
}
Enter fullscreen mode Exit fullscreen mode

We created AddressFormComponent. It has an input property called formGroup, through which parent components can pass the form-group. Let’s take a look at template:

<!-- src/app/nested-form-group-child/address-form/address-form.component.html -->

<div [formGroup]="addressFormGroup">
  <h2>Address</h2>
  <div>
    <label for="line1">Line 1*</label>
    <input
      [id]="'line1'"
      [name]="'line1'"
      autocomplete="address-line1"
      formControlName="line1"
      required
      type="text"
      [ngClass]="{
        'is-invalid': line1FC?.invalid && (line1FC?.touched || line1FC?.dirty)
      }"
    />
    <div class="invalid-feedback">Address line 1 is required</div>
  </div>
  <!-- other address fields -->
</div>
Enter fullscreen mode Exit fullscreen mode

The template is pretty simple, it just has the address related fields. And below is how we would use it in parent component:

<!-- src/app/nested-form-group-child/nested-form-group-child.component.html -->

<form [formGroup]="profileFormGroup">
  <!-- basic fields →

  <!-- notice the usage of child component -->
  <app-address-form [formGroup]="addressFormGroup"></app-address-form>

  <button type="submit" [disabled]="profileFormGroup.invalid">
    Submit form
  </button>
</form>
Enter fullscreen mode Exit fullscreen mode

And in the class, we would get the address form-group like below:

// src/app/nested-from-group/nested-from-group.component.ts

@Component({
  // ...
})
export class NestedFormGroupChildComponent {
  profileFormGroup = new FormGroup<ProfileForm>({ /*...*/ });

  get addressFormGroup() {
    return this.profileFormGroup.get("address") as FormGroup;
  }
}
Enter fullscreen mode Exit fullscreen mode

Pros

  1. Simpler templates a. With this approach now we can handle everything related to address in AddressFormComponent so that our main component is much simpler to handle.

Cons

  1. Not completely re-usable a. If you want to use this component as a stand-alone form component, it’s not possible for now

How to create reusable form with Composite ControlValueAccessor

In the previous section, we created a component and moved the address form’s template into it. But, it’s still not completely re-suable. By completely re-usable what I mean is:

  1. The logic, error handling and address form creation should be part of address-component and not the parent component
  2. The component should be usable inside any form
  3. The component should be usable with both, template driven and reactive forms
  4. The component should support native form-control features, so that the parent or consumer component can take advantage of that, for example showing errors if overall address-form is invalid

In this section we will learn how to create a completely reusable form, that can be used as both, inside other forms and as a stand-alone form. And the technique which we are going to use is called “Composite ControlValueAccessor”, referred from Kara Erickson in AngularConnect 2017.

Our goal is to use the address form something like below:

<!-- src/app/reusable-form/reusable-form-wrapper/reusable-form-wrapper.component.html -->

<form [formGroup]="profileFormGroup">
  <!-- other basic information fields -->
  <app-reusable-form formControlName="address"></app-reusable-form>
</form>
Enter fullscreen mode Exit fullscreen mode

ControlValueAccessor

A ControlValueAccessor acts as a bridge between the Angular forms API and a native element in the DOM. Any component or directive can be turned into form-control by implementing the ControlValueAccessor interface and registering itself as an NG_VALUE_ACCESSOR provider.

Among others the interface defines two important methods — writeValue and registerOnChange.

The writeValue method is used by formControl to set the value to the native form control. The registerOnChange method is used by formControl to register a callback that is expected to be triggered every time the native form control is updated. It is your responsibility to pass the updated value to this callback so that the value of respective Angular form control is updated. The registerOnTouched method is used to indicate that a user interacted with a control.

Here is the diagram that demonstrates an interaction:

How ControlValueAccessor works

From Never again be confused when implementing ControlValueAccessor in Angular forms - Angular inDepth

To learn more about ControlValueAccessor, you can read this indepth guide. If you are looking for a practical example, you can refer to my earlier article about date input and tutorial about managing object in form-control.

Composite ControlValueAccessors

In earlier articles and tutorials, we saw usage of ControlValueAccessor with one &lt;input>. But, the advantage of using ControlValueAccessor is you can have more inputs with it if you want.

We just need to handle all 4 required methods of that interface and it will just work, no matter how we handle it internally.

Reusable Form

One thing from previous section is clear, we want to use the same template and internally, we will manage the fields’ states with a form-group, so our template will look like below:

<!-- src/app/reusable-form/reusable-form.component.html -->

<div role="group" [formGroup]="form">
  <div>
    <label for="line1">Line 1*</label>
    <input
      id="line1"
      name="line1"
      autocomplete="address-line1"
      formControlName="line1"
      required
      type="text"
      [ngClass]="{
        'is-invalid': line1FC?.invalid && (line1FC?.touched || line1FC?.dirty)
      }"
    />
    <div class="invalid-feedback">Address line 1 is required</div>
  </div>
  <!-- other address fields -->
</div>
Enter fullscreen mode Exit fullscreen mode

To manage the address object, we will create a class. This will be used in later stage in class of above template:

// src/app/reusable-form/address.ts

export class Address {
  constructor(
    public line1: string = "",
    public zipCode: string = "",
    public city: string = "",
    public state: string = "",
    public country: string = "",
    public line2?: string | null
  ) {}

  isValid() {
    return (
      this.line1 && this.city && this.country && this.state && this.zipCode
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

We will have the above Address class as value for the form. Just like string, number and boolean, we can also have object stored in form-control. You can read more about it at the tutorial about managing object in form-control.

And for validation, you can have your own logic to validate the address, but for now we will keep it simple. We will use this when we implement validation.

Next, based on previous articles about ControlValueAccessor, we will start with implementing ControlValueAccessor interface and the NG_VALUE_ACCESSOR as provider:

// src/app/reusable-form/reusable-form.component.ts

@Component({
  selector: "app-reusable-form",
  templateUrl: "reusable-form.component.html",
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: ReusableFormComponent,
      multi: true,
    },
  ],
})
export class ReusableFormComponent implements ControlValueAccessor {
  form = new FormGroup<AddressForm>({
    line1: new FormControl("", {
      validators: Validators.required,
      nonNullable: true,
    }),
    // other address fields' form-controls
  });

  writeValue(value: Address | null): void {}

  registerOnChange(fn: (val: Partial<Address> | null) => void): void {}

  registerOnTouched(fn: () => void): void {}

  setDisabledState(disabled: boolean) {}
}
Enter fullscreen mode Exit fullscreen mode

writeValue

// src/app/reusable-form/reusable-form.component.ts

  writeValue(value: Address | null): void {
    const address = this.createAddress(value);
    this.form.patchValue(address);
  }

  private createAddress(value: Partial<Address> | null) {
    return new Address(
      value?.line1 || "",
      value?.zipCode || "",
      value?.city || "",
      value?.state || "",
      value?.country || "",
      value?.line2
    );
  }
Enter fullscreen mode Exit fullscreen mode

In this, we will first convert the value coming in to the instance of Address and then, will call form.patchValue and it will update all the fields, of which values are updated.

registerOnChange

// src/app/reusable-form/reusable-form.component.ts

registerOnChange(fn: (val: Partial<Address> | null) => void): void {
    this.form.valueChanges.subscribe((value) => {
      const address = this.createAddress(value);
      fn(address);
    });
  }
Enter fullscreen mode Exit fullscreen mode

We are using valueChanges observable to handle registerOnChange. valueChanges observable emits for all the child form-fields, so it is a perfect fit for registerOnChange.

Now, as we are listening for valueChanges, we will have to revisit and fix writeValue’s patchValue, because everytime patchValue is called, valueChanges will trigger, we don’t want that.

// src/app/reusable-form/reusable-form.component.ts

  writeValue(value: Address | null): void {
    const address = this.createAddress(value);
    this.form.patchValue(address, { emitEvent: false });
  }
Enter fullscreen mode Exit fullscreen mode

As you notice above, we are setting emitEvent to false, so that it doesn’t trigger valueChanges.

registerOnTouched

// src/app/reusable-form/reusable-form.component.ts

onTouched: any;

registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
Enter fullscreen mode Exit fullscreen mode

We want the form to be touched, anytime any child form-control is touched. So, we will call onTouched in the template as well, with all child form-controls:

<!-- src/app/reusable-form/reusable-form.component.html -->

<div role="group" [formGroup]="form">
  <div>
    <label for="line1">Line 1*</label>
    <!-- removed other attributes/properties for brevity -->
    <input
      (blur)="onTouched()"
    />
  </div>
  <!-- other address fields -->
</div>
Enter fullscreen mode Exit fullscreen mode

setDisabledState

// src/app/reusable-form/reusable-form.component.ts

setDisabledState(disabled: boolean) {
    disabled ? this.form.disable() : this.form.enable();
  }
Enter fullscreen mode Exit fullscreen mode

Validation

To handle validation, we will first add provider, and implement the Validator interface:

// src/app/reusable-form/reusable-form.component.ts

@Component({
  // ...
  providers: [
    // ...
    {
      provide: NG_VALIDATORS,
      useExisting: ReusableFormComponent,
      multi: true,
    },
  ],
})
export class ReusableFormComponent implements ControlValueAccessor, Validator {

  // ...
  validate(control: AbstractControl<Address>): ValidationErrors | null {}
}
Enter fullscreen mode Exit fullscreen mode

validate

// src/app/reusable-form/reusable-form.component.ts

validate(control: AbstractControl<Address>): ValidationErrors | null {
    const value = control.value;
    return value && value.isValid() ? null : { address: true };
  }
Enter fullscreen mode Exit fullscreen mode

Here we are going to use the Address class’s isValid method and based on that we will return the error.

Multiple instances

Although our form is ready now to be used, there is still one issue with it. If this component is being used within the same UI, then the form controls may not work properly, because they all will have have same name and id attributes. And, this component itself doesn’t have id, which can be helpful for testing.

To fix this, first let’s introduce an host property called id:

// src/app/reusable-form/reusable-form.component.ts

@Component({
  // ...
  host: {
    "[id]": "id",
  },
})
export class ReusableFormComponent {
  id = `address-input`;
}
Enter fullscreen mode Exit fullscreen mode

Next, we will add a static counter, which we will increment with each instance of this component:

// src/app/reusable-form/reusable-form.component.ts

@Component({
  // ...
})
export class ReusableFormComponent {

  static nextId = 0;
  id = `address-input-${ReusableFormComponent.nextId++}`;

}
Enter fullscreen mode Exit fullscreen mode

And lastly, we will use this id with all inputs’ id and name attributes. And also in their &lt;label>s’ for attribute:

<!-- src/app/reusable-form/reusable-form.component.html -->

<div role="group" [formGroup]="form">
  <div>
    <label [htmlFor]="id + '-line1'">Line 1*</label>
    <!-- other attributes removed for brevity -->
    <input
      [id]="id + '-line1'"
      [name]="id + '-line1'"
    />
  </div>
  <!-- other address fields -->
</div>
Enter fullscreen mode Exit fullscreen mode

With this, our completely reusable address form is ready.

Conclusion

In this tutorial we learned about how to create and use form-groups. Then we learned about how to create nested form-groups and how to use it with child components. And with each of those approaches, we also learned their pros and cons.

In the last, but very important section, we learned about how to create a completely reusable form component with composite ControlValueAccessor.

I have uploaded the code at GitHub, you can also take a look at it on stackblitz.

Top comments (0)