This was originally published on Angular In Depth.
In the past, the AngularInDepth blog has included some very helpful articles showing how the ReactiveFormsModule in @angular/forms can make your life easier.
- Unleash the power 💪of Forms with Angular’s Reactive Forms
- Dive into Reactive Forms
- Angular: Nested Reactive Forms Using ControlValueAccessors(CVAs)
Today, we’re going to talk about some of the problems with the ReactiveFormsModule and discuss a proposal to fix many of these problems. The formal proposal can be found as an issue in the Angular repo #31963 (it appears to be the fastest growing issue at the moment¹). The goal of this post is to encourage feedback from the community on improving the ReactiveFormsModule and fixing some of its longstanding issues.
So you may be wondering, what issues are there with the ReactiveFormsModule? Some of the biggest issues are:
- See issues #13721 #27389 #27665 #25824 #20040 #17000 #16999 #16933 relating to controls.
- See issues #31801 #19340 #19329 relating to ControlValueAccessor.
3. It’s relatively complicated to *add* error messages, including interfacing with async services for validation (hence the need for different update strategies like “on blur" / “on submit").
- You can’t bind a single form control to multiple inputs without ControlValueAccessor #14451
- Can’t store arbitrary metadata on a control #19686
- Calling reset() doesn't actually reset the control to its initial value #20214 #19747 #15741 #19251
- Must call markAsTouched() / markAsUntouched() instead of simply markTouched(boolean), which is more programmatically friendly #23414 #23336
- Creating custom form components is relatively complex #12248
- etc. #11447 #12715 #10468 #10195 #31133
5. In addition to all the issues dealing with errors, the API does not offer low level programmatic control and can be frustratingly not extensible.
- See issues #3009 #20230 related to parsing/formatting user input
- See issues #31046 #24444 #10887 #30610 relating to touched/dirty/etc flag changes
- See issues #30486 #31070 #21823 relating to the lack of ng-submitted change tracking
- Ability to remove FormGroup control without emitting event #29662
- Ability to subscribe to FormGroup form control additions / removals #16756
- Ability to mark ControlValueAccessor as untouched #27315
- Provide ControlValueAccessors for libraries other than @angular/forms #27672
Fundamentally, the existing AbstractControl class does not offer the extensibility / ease of use that such an important object should have. It’s unlikely that any one API could solve everyone’s problems all of the time, but a well designed API solves most peoples problems the majority of the time and can be extended to solve problems of arbitrary complexity when needed.
What follows is a proposal for a new AbstractControl API powered by a ControlEvent interface. In general, this proposal addresses issues 1, 3, 4, and 5, above. Importantly, this proposal is a completely community driven effort. The Angular team has not provided any feedback in regards to this proposal.
- The Angular issue associated with this proposal can be seen here: https://github.com/angular/angular/issues/31963
- The github repo for this proposal can be seen here: https://github.com/jorroll/reactive-forms-2-proposal. The repo includes working implementations of everything discussed here.
- A prototype module for the proposal has been published on npm at reactive-forms-module2-proposal this is just suitable for experimentation!
The github repo also contains stackblitz examples of the proposed API in action. The stackblitz demo also contains an example compatibility directive, letting the new AbstractControl be used with existing angular forms components (such as @angular/material components).
The proposed AbstractControl class has a source: ControlSource<PartialControlEvent> property which is the source of truth for all operations on the AbstractControl. The ControlSource is just a modified rxjs Subject. Internally, output from source is piped to the events observable, which performs any necessary actions to determine the new AbstractControl state before emitting a new ControlEvent object describing any mutations which occurred. This means that subscribing to the events observable will get you all changes to the AbstractControl.
With this relatively modest change, we can accomplish a whole host of API improvements. Let’s walk through some of them by example, before looking at the ControlEvent API itself.
Alternatively, you can scroll down and skip to the “_Diving into the ControlEvent API” section, below._
It’s important that the new API be very familiar to users of the existing ReactiveFormsModule, and be 100% usable by folks who don't want to use observables.
The new API allows us to subscribe to the changes of any property. When applied to ControlContainers such as FormGroup and FormArray, we can subscribe to nested child properties.
Importantly, in this example, if the address FormGroup is removed, then our subscription will emit undefined. If a new address FormGroup is added, then our subscription will emit the new value of the street FormControl.
This also allows us to subscribe to controls changes of a FormGroup/ FormArray.
Here, by subscribing the source of controlB to the events of controlA, controlB will reflect all changes to controlA.
Multiple form controls can also be linked to each other, meaning that all events to one will be applied to the others. Because events are keyed to source ids, this does not cause an infinite loop.
Manually syncing changes between controls, as shown in Example 4, above, can be somewhat of a hassle. In most cases, we just want to parse the user input coming from an input element and sync the parsed values.
To simplify this process, FormControlDirective/ FormControlNameDirective/etc accept optional "toControl", "toAccessor", and "accessorValidator" functions.
Here, a usernameControl is receiving text value from a user and we want to validate that input with an external service (e.g. "does the username already exist?").
Some things to note in this example:
- When a subscription to the usernameControl's value property emits, the control will already be marked pending .
- The API allows users to associate a call to markPending() with a specific key (in this case "usernameValidator"). This way, calling markPending(false) elsewhere (e.g. a different service validation call) will not prematurely mark this service call as "no longer pending". The AbstractControl is pending so long as any key is true.
- Similarly, errors are stored associated with a source. In this case, the source is 'usernameValidator'. If this service adds an error, but another service later says there are no errors, that service will not accidentally overwrite this service's error. Importantly, the errors property combines all errors into one object.
Note: it’s important to emphasize that, for standard usage, developers don’t need to know about the existence of the ControlEvent API. If you don't like observables, you can continue to simply use setValue(), patchValue(), etc without fear. For the purposes of this post however, lets look under the hood at what is going on!
At the core of this AbstractControl proposal is a new ControlEvent API which controls all mutations (state changes) to the AbstractControl. It is powered by two properties on the AbstractControl: source and events.
To change the state of an AbstractControl, you emit a new PartialControlEvent object from the source property. This object has the interface
When you call a method like AbstractControl#markTouched(), that method simply constructs the appropriate ControlEvent object for you and emits that object from control's ControlSource (which itself is just a modified rxjs Subject).
Internally, the AbstractControl subscribes to output from the source property and pipes that output to a protected processEvent() method. After being processed, a new ControlEvent object containing any changes is emitted from the control's events property (so when a subscriber receives a ControlEvent from the events property, any changes have already been applied to the AbstractControl).
You’ll notice that only events which haven’t yet been processed by this AbstractControl are processed (i.e. !event.processed.includes(this.id)). This allows two AbstractControls to subscribe to each other's events without entering into an infinite loop (more on this later).
You can check out the github repo to see the full AbstractControl interface proposal, as well as working implementations of FormControl, FormGroup, FormArray, etc.
Now that we know a bit more about the ControlEvent API, lets look at some examples it allows…
Say we have two FormControl’s and we want them to have the same state. The new API provides a handy AbstractControl#replayState() method which returns an observable of the ControlEvent state changes which describe the current AbstractControl's state.
If you subscribe one FormControl’s source to the replayState() of another form control, their values will be made equal.
The replayState() method also provides a flexible way of "saving" a control state and reapplying all, or parts of it, later.
Say you are changing a control’s value programmatically via a “service A”. Separately, you have another component, “component B”, watching the control’s value changes and reacting to them. For whatever reason, you want component B to ignore value changes which have been triggered programmatically by service A.
In the current ReactiveFormsModule, you can change a control's value and squelch the related observable emission by passing a "noEmit" option. Unfortunately, this will affect everything watching the control's value changes. If we only want componentB to ignore a values emission, we're out of luck.
With this new API, we can accomplish our goal. Every method which mutates an AbstractControl’s state accepts a meta option to which you can pass an arbitrary object. If you subscribe directly to a control's events, then we can view any passed metadata.
Here, the subscription in the ngOnInit() hook ignores changes with the myService: true meta property.
Let’s use this proposal’s FormControlDirective implementation as an example (full code can be seen in the github repo). Say you're creating a custom directive which exposes a public FormControl, and you wish to provide "lifecycle hooks" for subscribers of that FormControl.
In the specific case of the FormControlDirective, I wanted the ability for a ControlValueAccessor connected to a FormControlDirective to be notified when the "input" control of the FormControlDirective changed.
Admittedly, this is an advanced use case. But these are precisely the kinds of corner cases which the current ReactiveFormsModule handles poorly. In the case of our new API, we can simply emit a custom event from the control's source. The control won't actually do anything with the event itself, but will simply reemit it from the events observable. This allows anything subscribed to the events observable to see these custom events.
In this example, a custom ControlAccessor might want to perform special setup when a new input control is connected to MyFormControlDirective.
This far, we’ve focused on changes to the AbstractControl API. But some of the problems with the ReactiveFormsModule stem from the ControlValueAccessor API. While the ControlEvent API presented thus far doesn't rely on any assumptions about the ControlValueAccessor API, and it will work just fine with the existing ControlValueAccessor interface, it also allows for a big improvement to the ControlValueAccessor API.
At the risk of introducing too many new ideas at one time, lets look at how we can improve ControlValueAccessor using the new ControlEvent API...
As a reminder, the existing ControlValueAccessor interface looks like
The proposed ControlEvent API allows for a new ControlAccessor API which looks like:
With this update, the control property of a directive implementing ControlAccessor contains an AbstractControl representing the form state of the directive (as a reminder, components are directives).
This would have several advantages over the current ControlValueAccessor API:
- When the form is touched, mark the control as touched.
- When the form value is updated, setValue on the control.
3. Allows a ControlAccessor to represent a FormGroup / FormArray / etc, rather than just a FormControl
- A ControlAccessor can represent an address using a FormGroup.
- A ControlAccessor can represent people using a FormArray.
- You can pass metadata tied to changes to the ControlAccessor via the meta option found on the new AbstractControl.
- You can create custom ControlEvents for a ControlAccessor.
- If appropriate, you can access the current form state of a ControlAccessor via a standard interface (and you can use the replayState() method to apply that state to another AbstractControl)
- If appropriate, a ControlAccessor could make use of a custom control object extending AbstractControl.
As a refresher, here is a simple custom ControlValueAccessor implemented using the existing interface:
Here is the same component implemented using the proposed ControlAccessor interface:
If we want to programmatically mark this ControlAccessor as touched, we can simple call this.control.markTouched(true). If we want to programmatically update the value, we can simply setValue(), etc.
Lets look at a few more advanced examples of the benefits of the new ControlAccessor API:
Here, we create a custom form control component for an email address. Our custom component performs async validation of input email addresses using a userService. Similarly to Example 6, we mark the component as pending and debounce user input so that we don't make too many requests to our external service.
Here, we create a “user form” component which encapsulates the input fields for our user form. We also make use of our custom email address input component from the previous example. This control accessor represents its value using a FormGroup, something which is not possible using the current ControlValueAccessor API.
- I’ll also note that, because this component is also a ControlContainerAccessor, the use of formControlName will pull directly from the app-user-form component's control property. I.e. in this case, we don't need to use a [formGroup]='control' directive inside the component's template.
Here, we utilize our custom “user form” component (created in the previous example) as part of a signup form. If the user attempts to submit the form when it is invalid, we grab the first invalid control and focus it.
While fixing the existing ReactiveFormsModule is a possibility, it would involve many breaking changes. As Renderer -> Renderer2 has shown, a more user friendly solution is to create a new ReactiveFormsModule2 module, deprecate the old module, and provide a compatibility layer to allow usage of the two side-by-side (including using a new FormControl with a component expecting an old ControlValueAccessor).
There is also a lot more to this proposal than what was covered here.
- To take a look at the code and the current state of the proposal, as well as view stackblitz examples, head on over to the repo: https://github.com/jorroll/reactive-forms-2-proposal.
- To provide your support or disapproval for the proposal, head on over to its Angular issue: https://github.com/angular/angular/issues/31963.
- To provide feedback, make Pull Requests / contributions, etc, head on over to the github repo: https://github.com/jorroll/reactive-forms-2-proposal.
A lot of the issues with the current FormControl API are ultimately issues with the current ValidatorFn / ValidationErrors API.
1. If a control is required, a [required] attribute is not automatically added to the appropriate element in the DOM.
- Similarly, other validators should also include DOM changes (e.g. a maxLength validator should add a [maxlength] attribute for accessibility, there are ARIA attributes which should be added for accessibility, etc).
- If you validate to make sure an input is a number, it’s appropriate to add a type="number" attribute on the underlying <input>.
2. Generating and displaying error messages is much harder than it should be, for such a fundamental part a Forms API.
Ultimately, I see these as failings of the current ValidatorFn / ValidationErrors API, and should be addressed in a fix to that API. Any such fix should be included in any ReactiveFormsModule2 and can be incorporated into this AbstractControl API, but are currently out of scope for this particular proposal.
head on over to Angular issue #31963.
- The “fastest growing issue” statement is based off the fact that, in 3 months, the issue has risen to the second page of the Angular repo’s issues when sorted by “thumbsup” reactions. It is the only issue on the first 4 pages to have been created in 2019.