Skip to content

Building complex forms with Angular Reactive Forms

Building complex reactive forms in Angular that require a lot of cross-field validation and triggering side effects isn't an easy task. In this article we'll see one possible way of building such a form using the form service approach.

Published
Feb 19, 2023
Updated
Feb 19, 2023
Reading time
6 min read

I already wrote twice about Angular Reactive Forms. Handling large reactive forms in Angular in 2020 and the updated version using type safe reactive forms in 2022. And I think it's time for a third (and hopefully last) article about that topic. Today, we are gonna talk about a different approach that tackles the complexity of forms that comes with cross-field validation and side effects.

Large, typed reactive forms

First, let's take a look back at the approach I described in my previous articles.

Overview

If you are already familiar with my previous articles you can skip this section.

In my previous articles I described the following approach for creating large, reactive forms: the form is split into multiple components rendering the UI. Each of these components creates its own reactive form. The components provide their reactive form via @Output() event to the root component. The root component combines all of these reactive forms into a single reactive form, which is used to detect the validation status and to disable the form on submit.

Architecture overview that shows the parent component with a form group and two child components with their own form groups.

Pros & Cons

As with every solution, the approach has pros and cons. First, I still think it's a good approach that works fine for some use cases. Splitting the form into multiple components helps to keep the components small and relatively simple. Combining all reactive forms into one reactive form in the root gives you access to the validation status.

However, there are certainly a few drawbacks. If your form has a lot of cross-field validations and side effects (like resetting field x when field y changes), it gets really complicated and messy, sometimes violating the unidirectional data flow of Angular, resulting in errors.

An alternative approach for handling such complex forms is the form service approach.

Form service approach

If a form has a lot of cross-field validations and side effects, it's easier to handle them in a single place. When we split the form and distribute them across child components, it's so much harder to handle such stuff. Doing everything in one component is neither a good solution, as the form UI often requires a lot of code. Here, the form service approach can help.

The approach uses a single reactive form that contains all fields, validations and handles side effects. Doing this in a single place makes the code easier to implement and understand. For the UI part, we still create multiple child components to make them smaller and easier to maintain. We move our reactive form out of the component into a service that we provide on the root component. Using a service allows us to share the reactive form between all components.

Implementation

We implement a simple user form as an example. First, we create the UserFormService which holds the reactive form. Note that the service does not use providedIn: root but just the @Injectable() decorator.

@Injectable()
class UserFormService {
  readonly form = inject(FormBuilder).group({
    firstName: ["", [Validators.required]],
    lastName: ["", [Validators.required]],
    email: ["", [Validators.required, Validators.email]],
  });
}

Next, we create the root component and add the service to the providers array. This will create a new service instance for each instance of the FormComponent.

@Component({
  selector: "app-user-form",
  standalone: true,
  imports: [ReactiveFormsModule],
  providers: [UserFormService],
  template: `<!-- TODO -->`,
})
class UserFormComponent {}

And then we implement the UI in multiple child components. The child components inject the UserFormService and access the reactive form.

@Component({
  selector: "app-child1",
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <div [formGroup]="form">
      <!-- ... (Form Fields) -->
    </div>
  `,
})
class Child1Component {
  readonly form = inject(UserFormService).form;
}

Finally, we add the child components in our main form component and handle the submit event:

@Component({
  template: `
    <form (ngSubmit)="onSubmit()">
      <app-child1 />
      <app-child2 />
      <app-child3 />
    </form>
  `,
})
class UserFormComponent {
  readonly form = inject(UserFormService).form;

  onSubmit() {
    if (this.form.invalid) {
      // ... (focus first invalid field)
      return;
    }

    this.form.disable();
    console.log("Submitting form...", this.form.value);
  }
}

Cross-field validations and side effects are now straightforward to implement in the user form service. As an example, we could derive the email address from the name (given that you know the email provider), until the user manually edits the email address.

class UserFormService {
  readonly form = inject(FormBuilder).group({
    /* ... */
  });

  constructor() {
    // Trigger side effects for the form
    this.deriveEmailFromName();
  }

  private deriveEmailFromName() {
    const email$ = this.form.valueChanges.pipe(
      filter((value) => !!(value.firstName && value.lastName)),
      map((value) => `${value.firstName}.${value.lastName}@company.io`),
      map((email) => email.toLowerCase()),
      distinctUntilChanged()
    );

    email$
      .pipe(filter(() => this.form.controls.email.pristine))
      .subscribe((email) => this.form.patchValue({ email }));
  }
}

As you have access to the whole reactive form, implementing them is much easier than when the reactive form is split across multiple child components.

You can create additional services for more complex forms, e.g. to provide derived state for the form components, to handle complex validations or to the transform the form values.

Comparison

Compared to the first approach, there are a few pros:

  • Cross-field validations are much easier to handle then if they would be distributed across multiple child components.
  • Side effects are much easier to handle.
  • The components are easier to understand because the reactive form is extracted into a service.
  • The form UI is still split into multiple components that are small and easy to maintain.

But sure there are a few cons:

  • The UI components are not self-contained, they rely on a service for their form.
  • The UI components are not (or less) reusable. They are bound to the form service and reusing them in a completely different form does not work out of the box (which is possible with my previous approach). It's still possible if you work with interfaces and dependency injection, but definitely harder to implement.

Demo

The source code of the demo is available on GitHub: @rothsandro/ng-complex-reactive-forms

Summary

The form service approach extracts the reactive form into a service that handles the complete reactive form and is responsible for triggering side effects. The UI is still split into multiple, maintainable child components.

Which approach you use is up to you and depends on your requirements. But I've had better experience with this approach then with the one described in my previous articles.

Thanks for reading.