Article

Reusing and Nesting Reactive Forms in Angular

Published on February 23, 2022 by Laurens Van Keer

When creating Angular apps with forms, you will most likely run into the situation where you need to reuse a form.

This article is a review of a few approaches and their pro's and cons.

Use cases

Let's consider two examples of what we are talking about;

Reusing a form inside multiple pages

The base case, very typical in any CRUD app: the "create" and "update" pages are reusing the same form, except for header and button labels.

As an example, here is a wireframe of two pages reusing the same ContactFormComponent:

Wireframe example of pages reusing the same form

Nesting forms inside larger forms

A more complex case of reusing and nesting forms are subforms inside larger forms.

For example, suppose we have a form for adding "suppliers". Each supplier has a contact person, so we want to reuse the ContactFormComponent we created earlier inside the SupplierFormComponent:

Wireframe example of a nested form

Angular solutions for reusing and nesting forms

Let's consider our options:

  1. "Do Repeat Yourself": Re-create the forms and their form controls for each new page
  2. 1 form = 1 component
  3. Passing form data through inputs and outputs
  4. Taking a FormGroup as input
  5. Accessing the (sub)form using ViewChild
  6. Taking a typed FormGroup as input
  7. Using the ControlValueAccessor API to turn forms into form controls

Note that we only consider reactive forms here, but similar options would probably apply to template-driven forms.

1. "Do Repeat Yourself"

Senior devs will cringe at this terrible bastardization of the usual meaning of the DRY acronym, but sometimes we need to Keep It Simple ((and) Stupid). Sometimes we just need to roll out a prototype, or an MVP, fast, and the team consists of mostly junior developers, or backend developers, that need to create a frontend fast. No time for learning the ins-and-outs of the Angular framework. Just get it done.

This is when copy/paste and recreating each form control in each page could be done, but let's not consider this any further.

2. 1 Form = 1 Component

This approach enhances reusability by creating a single component for every form in the app. For each variation of the form (e.g. "create" vs "update" have different button labels), we provide a "form type" input. Depending on the input parameters, different labels and/or different functionality can be enabled.

1 Form 1 Component example

This avoids the code duplication that we would have in the first approach, but nesting forms is still impossible (because the submit button is included in the form component). Also, depending on the complexity of what you are implementing, this type of solution could end up in spaghetti code consisting of many inputs and/or *ngIf usages and other conditional branches.

3. Form components that use inputs and outputs for passing data

A junior developer who understood the principle of component composition and passing data through inputs and outputs might suggest this approach.

Here, we create our ContactFormComponent as yet another Angular component. Data can be passed from the parent container to the form component via an input property. Changes to the form are emitted back to its consumer using an output EventEmitter.

A naive implementation could look like this:

export class ContactFormComponent implements OnInit, OnDestroy {
  @Input() contact: Contact;
  @Output() changeContact = new EventEmitter<Contact>();

  form = this.fb.group({
    name: [''],
    // place other controls here
  });

  private readonly destroy$ = new Subject<void>();

  constructor(private readonly fb: FormBuilder) {
    console.log('ContactFormComponent constructed');
  }

  ngOnInit(): void {
    this.form.setValue(this.contact);
    this.form.valueChanges
      .pipe(
        takeUntil(this.destroy$)
      )
      .subscribe({
        next: (formValue) => {
          this.changeContact.emit(formValue);
        },
      });
  }

  ngOnDestroy(): void {
    this.destroy$.next();
  }
}

Why is this the "naive" implementation? Because changes to the contact input property (from the parent) are not registered. Also, the form will not be initialized if the contact is passed AFTER the ngOnInit lifecycle hook (e.g. in the case of an HTTP response).

You could solve these issues by using setters. Then you will probably run into (possibly infinite) loops, cause by the parent component calling the input setter, which updates the form, which triggers the valueChanges observable, which in turn emits an output event, which will update the parent component property, which calls the input setter again, etc...

You can solve this too in a number of ways. Here's one for reference, but the point of this article is to show this approach has many (perhaps at first, hidden) complexities - and we haven't touched upon what to do to transmit validation changes and errors yet!

export class ContactFormComponent implements OnInit, OnDestroy {
  @Input() set contact(value: Contact) {
    this._contact = value || { name: '' };
    if (!_isEqual(value, this.form.value)) { // using Lodash deep equals function here, because we don't want to update the form if the value is the same
      this.form.setValue(this._contact);
    }
  }

  get contact(): Contact {
    return this._contact;
  }

  @Output() changeContact = new EventEmitter<Contact>();

  form = this.fb.group({
    name: [''],
    // place other controls here
  });

  private _contact: Contact;
  private readonly destroy$ = new Subject<void>();

  constructor(private readonly fb: FormBuilder) {
    this.form.valueChanges
      .pipe(
        // note that you could insert a debounce operator here...
        filter((value) => !_isEqual(value, this.contact)), // filter out unnecessary output events using the Lodash deep equals function
        takeUntil(this.destroy$)
      )
      .subscribe({
        next: (formValue) => {
          this.changeContact.emit(formValue);
        },
      });
  }

  ngOnDestroy(): void {
    this.destroy$.next();
  }
}

Basically, this approach will lead you to indirectly reinvent the FormControl API. So let's continue, what more alternatives do we have?

4. Passing an untyped FormGroup as input

Instead of reinventing the reactive forms API, we could simply pass an already existing FormGroup object as input to the form component:

@Component({
  selector: 'app-contact-form',
  template: `
    <form [formGroup]="form" class="container">
      <div>
        <label for="name">Name</label>
        <input id="name" name="name" type="text" formControlName="name" />
      </div>
    </form>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContactFormComponent {
  @Input() form: FormGroup;
}

However, the obvious downside is that, theoretically, we could pass any FormGroup as input, no matter which controls were defined. So while easy to implement, it's an error-prone solution.

5. Accessing the (sub)form using ViewChild

To avoid this dependency on parent components defining the correct FormGroup, we could create the FormGroup inside the form component, and access it directly using the ViewChild decorator. This also makes it easy to compose (nest) subforms.

// The reusable form component
@Component({
  selector: 'app-contact-form',
  template: `
    <form [formGroup]="form" class="container">
      <div>
        <label for="name">Name</label>
        <input name="name" type="text" formControlName="name" />
      </div>
    </form>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContactFormComponent {
  form = this.fb.group({
    name: [''],
    // add more controls here
  });

  constructor(private fb: FormBuilder) {}
}

// The container using the form directly
@Component({
  selector: 'app-add-contact',
  template: `
    <h1>Add Contact</h1>
    <app-contact-form></app-contact-form>
    <button (click)="save()">Add Contact</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddContactComponent {
  @ViewChild(ContactFormComponent, { static: true })
  contactForm: ContactFormComponent;

  save(): void {
    console.log('Saving contact...', this.contactForm.form.value);
  }
}

// Nesting the form inside a larger form
@Component({
  selector: 'app-add-supplier',
  template: `
    <h1>Add Supplier</h1>
    <form [formGroup]="form">
      <div>
        <label for="supplierName">Supplier Name</label>
        <input name="supplierName" type="text" formControlName="supplierName" />
      </div>
      <app-contact-form></app-contact-form>
    </form>
    <button (click)="save()">Add Contact</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddSupplierComponent implements OnInit {
  @ViewChild(ContactFormComponent, { static: true })
  contactForm: ContactFormComponent;

  form: FormGroup;

  constructor(private readonly fb: FormBuilder) {}

  ngOnInit(): void {
    this.form = this.fb.group({
      supplierName: [''],
      contact: this.contactForm.form,
    });
  }

  save(): void {
    console.log('Saving supplier...', this.form.value);
  }
}

6. Passing a typed FormGroup as input

ViewChild unfortunately hard-couples our parent component with the TypeScript implementation of our form component. Is there a best of both worlds solution, where the FormGroup is constructed in the form component, but we don't need to know about the implementation in the parent?

There is, and this is a solution that I haven't found elsewhere on the Web. My thanks to Thomas King for the productive discussions that led to this alternative solution.

The idea is to create a custom class that extends FormGroup. We can use this class to initialize the exact type of FormGroup that we need for our form component. Example:

export interface ContactForm {
  name: string;
}

const defaultContactForm: ContactForm = {
  name: '',
};

export class ContactFormGroup extends FormGroup {
  constructor(defaults: ContactForm = defaultContactForm) {
    super({
      name: new FormControl(defaults.name),
    });
  }
}

@Component({
  selector: 'app-contact-form',
  template: `
    <form [formGroup]="form" class="container">
      <div>
        <label for="contactName">Name</label>
        <input name="name" id="contactName" type="text" formControlName="name" />
      </div>
    </form>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContactFormComponent {
  @Input() form: ContactFormGroup;
}

And we can use it like this:

import { ContactFormGroup } from './contact-form.component';

@Component({
  selector: 'app-add-contact',
  template: `
    <app-contact-form [form]="form"></app-contact-form>
    <button (click)="save()">Add Contact</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddContactComponent {
  form = new ContactFormGroup();

  save(): void {
    console.log('Saving contact...', this.form.value);
  }
}

7. Using the ControlValueAccessor API

Sometimes we want use our form as if it was just another FormControl. This is when we implement the ControlValueAccessor API. I would forward you to this article about CVAs in Angular forms for the implementation details. I will perhaps bring my own take on CVAs and typical pitfalls in a future blog post.

My professional opinion about this approach is that it's excellent for creating reusable form controls, but not as the default solution for form components, especially if your team is not very experienced yet.

Conclusions

We've reviewed 7 ways to implement forms in Angular. Which one is right for you will depend on the type of application you are developing, degree of (future) reuse, the available time & budget, and the experience of the developer or the developing team.

For the average developer who just needs to build a reusable form component quickly, I would recommend passing a typed FormGroup, as I believe this approach finds a good balance between simplicity (easy to understand and implement), clean code (no hard coupling between your components) and flexibility (availability of the full Reactive Forms API).

For the advanced Angular team working on reusable presentation components, I would still recommend implementing the ControlValueAccessor API.

Photo credit: Kelly Sikkema

Tailored Application DevelopmentHire Our Expertise

We give strategic and technical advice for your digitalisation project.
We help your concepts come to live.
We coach and train your in-house developers.

Contact Us

© 2016 - 2023 App Vision BV. All rights reserved.
Lindekouter 9, 9420 Erpe-Mere
VAT BE0665619245