A proposal to improve Angular’s ReactiveFormsModule
AngularInDepth is moving away from Medium. More recent articles are hosted on the new platform inDepth.dev. Thanks for being part of indepth movement!
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:
1. The module is not strongly typed
- See issues #13721 #27389 #27665 #25824 #20040 #17000 #16999 #16933 relating to controls.
- See issues #31801 #19340 #19329 relating to ControlValueAccessor.
2. It’s relatively complicated to *display* error messages, given how fundamental this task is.
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"
).
4. Numerous annoyances with unfortunate API decisions.
- 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 simplymarkTouched(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 new AbstractControl
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.
Example 1
The new API is familiar for users of the old API
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.
Example 2
Subscribing to nested changes
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
.
Example 3
Linking one FormControl to another FormControl
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.
Example 4
Dynamically transform a control’s value
Here, a user is providing string
date values and we want a control with javascript Date
objects. We create two controls, one for holding the string
values and the other for holding the Date
values and we sync all changes between them. However, value changes from one to the other are transformed to be in the appropriate format.
Example 5
Dynamically parse user input
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.
In this example, we provide a stringToDate
function which receives an input string and transforms it into a javascript Date
, or null
if the string isn't in the proper format. Similarly, we provide a dateToString
function to sync our control's Date | null
values back to the input element. We also provide an optional accessorValidator
function to validate the input element's strings and provide helpful error messages to the user.
Example 6
Validating the value of an AbstractControl via a service
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 markedpending
. - The API allows users to associate a call to
markPending()
with a specific key (in this case"usernameValidator"
). This way, callingmarkPending(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 istrue
. - 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, theerrors
property combines all errors into one object.
Diving into the ControlEvent API
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…
Example 7
Syncing one FormControl’s value with another
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.
Example 8
Customizing AbstractControl state changes
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.
Example 9
Emitting “lifecycle hooks” from an AbstractControl
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
.
ControlValueAccessor
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:
1. Easier to implement
- When the form is touched, mark the control as touched.
- When the form value is updated, setValue on the control.
- etc
2. Easier to conceptualize (admittedly subjective)
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.
- etc
4. Very flexible
- 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.
Example 10
A simple example using the *existing* ControlValueAccessor
API
As a refresher, here is a simple custom ControlValueAccessor
implemented using the existing interface:
Example 11
A simple example using the *proposed* ControlAccessor
API
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:
Example 12
An email address input with async validation
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.
Example 13
A form group control accessor
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 offormControlName
will pull directly from theapp-user-form
component'scontrol
property. I.e. in this case, we don't need to use a[formGroup]='control'
directive inside the component's template.
Example 14
Nesting multiple form groups
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.
Conclusion
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.
Things not covered: the validators API
A lot of the issues with the current FormControl API are ultimately issues with the current ValidatorFn
/ ValidationErrors
API.
Examples include:
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.
To give your support or disapproval to the proposal:
head on over to Angular issue #31963.
Footnotes
- 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.