Guide

Deep dive into building a dynamic form.

Overview

Dynamic forms are a powerful tool in Angular for situations where you need to create forms with a similar structure but varying content, like questionnaires or quizzes. This approach allows you to define the form's shape using a JSON object, making it faster and easier to generate new versions. With dynamic forms, you can modify the questions on the fly without changing the application code itself. To learn more about building dynamic forms and their functionalities, check out this in-depth resource from Angular.

Defining the object

This section lays the groundwork for our dynamic form by defining its structure in a JSON object. This object acts as a blueprint, specifying the type and properties of each form element. We'll also create a corresponding TypeScript types to ensure type safety.

JSON

This would be the base JSON object for our form. The description could contain the title of the form. Inside the controls object, we'll specify the type and properties of each form element.

In this example we would have four types of form fields, input, select, checkbox, and group. We'll be defining their shape.

Input:

Select:

Checkbox:

Group:

The validators object is optional. But it is where we would resolve Angular validators. An example validators object is shown below. While this example includes all the validators used in this guide, it might not be the most practical. It serves to demonstrate the syntax of each validator.

Types

This is where we'll define the types based on the shape of the object. The types will essentially mirror the structure of the object, specifying the expected data type for each property.

src/app/shared/dynamic-forms/dynamic-forms.type.ts

We omit prototype, compose, and composeAsync from the type ValidatorKeys as they are not validators. These functions serve other purposes within the Angular framework and are not relevant to form validation.

You might notice that DynamicGroupControl is the only type that includes validators. To handle scenarios where validators are not needed, we've also defined the type DynamicControlWithoutGroup . We'll utilize this type later on when resolving validators.

Creating form building blocks

This section dives into creating the building blocks for our dynamic form. Here, we'll develop reusable components based on the defined controlTypes . These components will serve as the fundamental elements that make up the different form fields.

Creating injection token

We'll create an injection token to hold the necessary data for each dynamic control. This data will be injected later using dependency injection.

src/app/shared/dynamic-forms/control-data.token.ts

We use a generic type defaulting to DynamicControl . This enables us to employ type assertions on each dynamic control component, guaranteeing we use the appropriate type.

Common functionality

These are the common functionalities that would be used in our dynamic control components.

src/app/shared/dynamic-forms/dynamic-controls/base-dynamic-control.ts

We'll be creating the DynamicValidatorMessageDirective later in this guide. This directive will be used to display validation errors.

The comparatorFn would be used to display the controls in order.

The dynamicControlProvider is essential for defining controls outside the view of a form group. Without it, you'll encounter the error NG01050: formControlName must be used with a parent formGroup directive. We also use { skipSelf: true } so that it resolves the dependency from the parent injector. This is crucial for nested forms (forms that have another formGroup inside of it) to avoid errors.

We must also provide the dynamicControlProvider via viewProviders . By default, Angular restricts ControlContainer provider resolution only within the component's view where the formControlName directive is declared. This means Angular won't look for providers beyond that view, even if we defined them elsewhere. viewProviders work because Angular checks them before the ControlContainer resolution fails, unlike regular providers in this context.

Resolving Validators

Before we delve into creating dynamic control components, let's explore how we'll resolve control validators.

src/app/shared/dynamic-forms/resolve-validators.ts

This function iterates through the validators object and tries to convert each key into a corresponding Angular validator.

Dynamic Components

This guide uses the spartan-ng library. However, you have the flexibility to choose a different library that aligns with your project's requirements, or even create your own custom implementation.

src/app/shared/dynamic-forms/dynamic-controls/dynamic-input.component.ts

src/app/shared/dynamic-forms/dynamic-controls/dynamic-select.component.ts

src/app/shared/dynamic-forms/dynamic-controls/dynamic-checkbox.component.ts

src/app/shared/dynamic-forms/dynamic-controls/dynamic-group.component.ts

As you've seen, this pattern of injecting CONTROL_DATA and adding the control to the ControlContainer repeats across dynamic control components. While creating a reusable class might seem like a way to avoid code duplication, it would prevent us from using type assertions, which are essential for ensuring type safety.

The dynamic group component utilizes controlInjector and controlResolver . We'll delve into these concepts in the next section.

Resolving the form

Here, we'll explore the final steps involved in setting up and resolving the form, making it ready for user interaction and data submission.

Lazy Loading Dynamic Components

We lazy load controls based on their controlType to avoid loading unused components and improve performance.

src/app/shared/dynamic-forms/dynamic-control-resolver.service.ts

The resolve function acts like a cache for dynamic control components. It first checks if the component for a given type is already loaded. If so, it returns it immediately. Otherwise, it fetches the component.

Injecting control data

To populate our dynamic control components with data, we'll leverage dependency injection. This technique allows us to access the CONTROL_DATA injection token, which provides the necessary data for each control.

src/app/shared/dynamic-forms/control-injector.pipe.ts

Creating the form

Assuming the JSON object is located at src/assets/form.json we'll demonstrate how to use it to create the form.

src/app/shared/dynamic-forms/dynamic-forms-page/dynamic-forms-page.component.ts

Similar to how we resolve controls within the DynamicGroupComponent we leverage NgComponentOutlet to project the content (a component in this case). Likewise, NgComponentOutletInjector is used to inject the necessary dependencies into the projected component.

Avoid using the track in the for loop. While it might not be crucial in this scenario where the form is generated only once, using track can lead to inconsistencies if you manipulate form controls (adding, editing, removing) from the formConfig object. Instead, re-register the controls whenever the formConfig object is modified.

Dynamic Error Handling

This section covers techniques for displaying and managing validation errors within your forms.

Resolving error messages

We'll be using dependency injection to resolve error messages. You might wonder why we wouldn't just hardcode them. While that's possible, dependency injection offers the benefit of message customization.

src/app/shared/dynamic-form-errors/input-error/validation-error-messages.token.ts

We use function as value so we could provide parameters that make the message more descriptive.

This is an example of how we can override the default error messages.

While using a pipe to match the appropriate error message is optional, it improves code readability. Additionally, a console warning is triggered if a key doesn't match any defined error message.

src/app/shared/dynamic-form-errors/error-message.pipe.ts

This component handles error display. We'll explore how to use it later.

src/app/shared/dynamic-form-errors/input-error/input-error.component.ts

Error state matcher strategy

This service determines whether or not to display error messages in our forms. We'll delve into its usage later.

src/app/shared/dynamic-form-errors/input-error/error-state-matcher.service.ts

Dependency injection allows us to easily switch between different error state matcher strategies.

In cases where you only want to change the default error state matcher for specific fields, rather than the entire component, you can do the following.

The directive we'll create in the next section will accept this custom error state matcher as an input.

Creating the error handler

This section dives into the details of creating the error handler.

src/app/shared/dynamic-form-errors/dynamic-validator-message.directive.ts

The selector eliminates the need to manually write a directive for each control. Simply import this directive, and you're set! Additionally, you have the option to opt out using the withoutValidationErrors property.

We inject the NgControl . However, since we're also using formGroup (which uses ControlContainer ), we make the NgControloptional . If NgControl isn't provided, we inject ControlContainer instead. We also use self so the injection comes from the directive injector.

We inject the ControlContainer to access the form's submit status later. Additionally, making this injection optional allows the directive to work with standalone form controls as well.

The container input could be used to render the error on a custom view slot.

We would be using the ValidatorMessageContainerDirective to get a reference on the slot we want to display the error message.

src/app/shared/dynamic-form-errors/input-error/validator-message-container.directive.ts

We use exportAs to get the instance of the directive instead of the native element.

Here is an example on how we could the ValidatorMessageContainerDirective along with the exportAs string.

We use queueMicrotask because ngModelGroup controls are resolved asynchronously. But since we create the observable stream in the constructor instead of OnInit , we need to use it regardless.

We combine the following triggers to know when to check if we have to display an error or not.

  • this.ngControl.control.statusChanges : For when the status changes.
  • fromEvent(this.elementRef.nativeElement, 'blur') : For when the user focuses on a field.
  • iif(() => !!this.form, this.form!.ngSubmit, EMPTY) : If the form exists, listen to the submit event, otherwise do not.

We use startWith to immediately emit the initial status of the control ( this.ngControl.control.status ) in the stream. This is necessary because reactive forms don't emit a status by default. However, in template-driven forms, using startWith would cause a double emission. To address this, we leverage the conditional operator (this.ngControl instanceof NgModel ? skip(1) : 0) . This skips the first emission only if we're working with a template-driven form, ensuring correct behavior for both form types.

If the errorStateMatcher matches: create the InputErrorComponent only if it doesn't yet exist. It then sets the components errors input. If the errorStateMatcher does not match then delete the component.