Skip to content

Instantly share code, notes, and snippets.

@jorroll

jorroll/blog.md Secret

Created November 4, 2019 22:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jorroll/89ead71d52e2ad8d25fbf15c23be590d to your computer and use it in GitHub Desktop.
Save jorroll/89ead71d52e2ad8d25fbf15c23be590d to your computer and use it in GitHub Desktop.

In the past, the AngularInDepth blog has included some very helpful articles showing how the ReactiveFormsModule in @angular/forms can make your life easier.

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 1). The goal of this post is to encourage feedback from the community on improving the ReactiveFormsModule and fixing some of it's 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
  2. It’s relatively complicated to display error messages, given how fundamental this task is. See #25824 #24981 #22319 #21011 #2240 #9121 #18114
  3. The methods for adding errors are inflexible. It is difficult to interface with async services to display errors (hence the need for different update strategies like on blur / on submit). In general, working with errors is more difficult than it should be.
  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 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 (#3 above), 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 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. Lets walk through some of them by example.

Alternatively, you can skip to an overview of the ControlEvent API.

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.

New API:

https://gist.github.com/7f5e47b57ba99cf014d77e27c5463a1e

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 the example below 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.

https://gist.github.com/82763147f4aff6d63145b3241b5294fa

Example 3

Linking one FormControl to another FormControl

This example can be seen on stackblitz

Here, by subscribing the source of controlB to the events of controlA, controlB will reflect all changes to controlA.

https://gist.github.com/3985baa0e5dcf5ab045e948550a76981

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.

https://gist.github.com/f170288fdf42c7babd34fee8c29f4079

Example 4

Dynamically transform a control's value

This example can be seen on stackblitz

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.

https://gist.github.com/09b8580ab33bfce266221839f399fdd5

Example 5

Dynamically parse user input

This example can be seen on stackblitz

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.

https://gist.github.com/818300ebe98fc4eae2a9a7c936bdb68a

Example 6

Validating the value of an AbstractControl via a service

This example can be seen on stackblitz

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?"). As with the other examples show, there is no obvious way of accomplishing this example with the current ReactiveFormsModule API.

https://gist.github.com/3c25a1521abed3d7535945bbcf121c5b

Some things to note in this example:

  1. 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.
  2. 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.
    1. Importantly, the errors 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 existance 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.

https://gist.github.com/946e56f57cc166e3efe3b1d7e45f3552

To change the state of an AbstractControl, you emit a new PartialControlEvent object from the source property. This object has the interface

https://gist.github.com/8c7ad9ae7c9036221e71c094350f1c03

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).

https://gist.github.com/0273b209c801e740878cca73dab24427

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).

https://gist.github.com/ec43f8c702354b925a0e8a04b9be7364

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 FormGroup / FormArray.

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.

https://gist.github.com/5f4b152670758435172f5542f7938f0d

The replayState() method also provides a flexible way of "saving" a control state and reapplying all, or parts of it, later.

https://gist.github.com/00e05aebaf1f0039828e89fee1b95aeb

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 proposal, things are much easier. 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.

https://gist.github.com/e64654d0eea2d00932ecfdf3c7818646

Example 9

Emitting "lifecycle hooks" from an AbstractControl

Lets 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.

https://gist.github.com/846dadf0814a15de677da3029f4a1a13

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

https://gist.github.com/438ecddfb89d5338f803cb961f154682

Proposed ControlAccessor Interface

The proposed ControlEvent API allows for a new ControlAccessor API which looks like:

https://gist.github.com/0b736e3b3232de7c228fc1241a476a8f

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 FirstNameInputComponent implemented using the existing (ReactiveFormsModule) ControlValueAccessor interface:

https://gist.github.com/de1149b72b42ba8dcab892379456c8cd

Example 11

A simple example using the proposed ControlAccessor API

Here is the same component implemented using the proposed ControlAccessor interface:

https://gist.github.com/4fd2d16d7796771de1a27930b80e011b

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.

https://gist.github.com/888651f031b7736bfbac3a41afda1f4f

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 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.

https://gist.github.com/ddbf8e9546c65d36d0940cfb90f33f1a

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.

https://gist.github.com/d5a7477d8463d28f15467b67c95a21b0

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/thefliik/reactive-forms-2-proposal.

To provide your support or disapproval for the proposal, head on over to its Angular issue: angular/angular#31963.

To provide feedback, make Pull Requests / contributions, etc, head on over to the github repo: https://github.com/thefliik/reactive-forms-2-proposal.

Things not covered: the validation 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.
    1. 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).
    2. 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 incorporated into this AbstractControl API, but is currently out of scope for this particular proposal.

Footnotes

  1. The "fastest growing issue" statement is based off the fact that, in 3 months, it 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment