An Experiment In Consuming Reactive-Form Events From Template-Driven Forms In Angular 7.2.13
Historically, I've only used template-driven forms in Angular. And, to be honest, they "just work" for me. Most of the form interfaces that I create are fairly simple and usually have a very low-level of validation requirements. As such, I've never felt much friction with them. However, many developers in the Angular community rave about the power of Reactive Forms; so, last week, I decided to take my first look at Reactive Forms in Angular. In response to that post, Lars Gyrup Brink Nielsen pointed out that my exploration didn't really leverage any of the "reactive" features of Reactive Forms. So, as a fun follow-up experiment, I wanted to see if I could access these reactive features directly from within my template-driven forms in Angular 7.2.13.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
At the end of the day, template-driven forms in Angular are really just a thin convenience layer over the underlying Reactive Form classes. As such, we can just inject the various Form Directives into our components if we want to access and bind-to the Reactive class event-streams.
For example, assuming that we have template variables for an NgForm instance with "#ngFormRef" and an NgModel instance with "#ngModelRef", we could just inject those implicitly into our component with a few ViewChild queries in our meta-data:
- ngFormRef: new ViewChild( "ngFormRef", { read: NgForm } )
- ngModelRef: new ViewChild( "ngModelRef", { read: NgModel } )
From these injected Directives, we could then access the underlying ".control" property, which would grant us access to the FormGroup and FormControl classes. These classes, in turn, expose the "valueChanges" and "statusChanges" Observable Streams that form the basis of the "reactive" form behaviors.
But, I don't want to do any of that. Instead, I want to have a bit more fun. And, keep things a bit more "template-driven". As such, instead of trying to access the reactive features within the component, I want to try and create a "reactive bridge" that exposes the reactive features directly to my template bindings. Then, in the same way that my templates can listen for (submit) and (ngModelChange) events, I want them to be able to listen for (valueChange) and (statusChange) events.
If nothing else, I think this experiment really demonstrates how wonderfully flexible Angular is. And, about how directives can be used like Lego pieces to create something very powerful. Angular Directives are kind of like "React Hooks" on Steroids.
To set the context of the conversation, let look at the component Template for this experiment. The following component template allows a pet name to be entered; and, has one of each template-driven form directive:
- NgForm
- NgModelGroup
- NgModel
Each of these directives is also being bound with a sibling directive: [reactiveBridge]. This ReactiveBridgeDirective is binding to the underlying Control and exposing two output EventEmitters:
- (valueChange) => EventEmitter<ReactiveBridgeEvent>
- (statusChange) => EventEmitter<ReactiveBridgeEvent>
Here is the HTML for the template:
<!--
In the following template, the [reactiveBridge] selector is attaching a directive
to the existing NgForm, NgModelGroup, and NgModel directives that exposes the two
(statusChange) and (valueChange) EventEmitters that are powered by the reactive
event streams of the underlying FormGroup and FormControl classes.
-->
<form
#ngFormRef="ngForm"
(submit)="handleSubmit( ngFormRef )"
reactiveBridge
(statusChange)="handleFormStatusChange( $event )"
(valueChange)="handleFormValueChange( $event )">
<!-- NgModelGroup is used to create a context for the "name" of our NgModel. -->
<span
#ngModelGroupRef="ngModelGroup"
ngModelGroup="pet"
reactiveBridge
(statusChange)="handleModelGroupStatusChange( $event )"
(valueChange)="handleModelGroupValueChange( $event )">
<input
#ngModelRef="ngModel"
type="text"
name="name"
[(ngModel)]="form.pet.name"
(ngModelChange)="handleModelChange( $event )"
required
reactiveBridge
(statusChange)="handleModelStatusChange( $event )"
(valueChange)="handleModelValueChange( $event )"
/>
</span>
<button type="submit" [disabled]="( ! ngFormRef.valid )">
Submit Form
</button>
</form>
<!--
Let's output the Path and State of the various NgForm directives so that we can see
that the normal NgForm (and related) directives expose state that we can use to drive
other user-interface styling.
-->
<ul>
<li>
<strong>NgForm[ {{ ngFormRef.path }} ]</strong>:
{{ ngFormRef.valid }}
</li>
<li>
<strong>NgModelGroup[ {{ ngModelGroupRef.path }} ]</strong>:
{{ ngModelGroupRef.valid }}
</li>
<li>
<strong>NgModel[ {{ ngModelRef.path }} ]</strong>:
{{ ngModelRef.valid }}
</li>
</ul>
As you can see, each of my template-driven form directives also has a [reactiveBridge] binding. We're then hooking into those EventEmitters and just sending the events to the App component (which we'll see in a minute).
This attribute, [reactiveBridge], is being used in the directive selector (broken-up here for readability):
- form[reactiveBridge]
- [ngModelGroup][reactiveBridge]
- [ngModel][reactiveBridge]
I chose to use the [reactiveBridge] attribute in order to make the behavior less surprising. I could just have easily made the selector more implicit so that all that the user had to do was add the (valueChange) or (statusChange) event-bindings:
- form[valueChange]
- form[statusChange]
- [ngModelGroup][valueChange]
- [ngModelGroup][statusChange]
- [ngModel][valueChange]
- [ngModel][statusChange]
As I've gotten older, however, I've been trying to make my Angular bindings less "clever" and more explicit. As such, going with the [reactiveBridge] acts a signal to the developer that something "non-native" is being made available in the template.
With that said, here's the App component. As you will see, all we're doing is logging out the events that are being sent from the template bindings:
// Import the core angular services.
import { Component } from "@angular/core";
import { NgForm } from "@angular/forms";
// Import the application components and services.
import { ReactiveBridgeEvent } from "./form-event-bridge.directive";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// NOTE: We could have used a ViewQuery() to get the "#ngForm" ref / NgForm directive
// instance injected into the app-component constructor. This would have given us access
// to ngForm.control - the underlying FormGroup. But, for the sake of the demo, I am
// looking to access the FormGroup events via event-bindings in the template. That's
// where the "reactive bridge" directive comes into play.
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
templateUrl: "./app.component.htm"
})
export class AppComponent {
public form: {
pet: {
name: string;
};
};
// I initialize the app component.
constructor() {
this.form = {
pet: {
name: "Lucy"
}
};
}
// ---
// PUBLIC METHODS.
// ---
public handleFormStatusChange( event: ReactiveBridgeEvent ) : void {
console.group( "form - reactiveBridge - (statusChange)" );
console.log( event );
console.groupEnd();
}
public handleFormValueChange( event: ReactiveBridgeEvent ) : void {
console.group( "form - reactiveBridge - (valueChange)" );
console.log( event );
console.groupEnd();
}
// NOTE: This isn't part of the reactive-bridge. This is just the normal "change"
// functionality of the NgModel directive.
public handleModelChange( event: string ) : void {
console.group( "ngModel(ngModelChange)" );
console.log( event );
console.groupEnd();
}
public handleModelGroupStatusChange( event: ReactiveBridgeEvent ) : void {
console.group( "ngModelGroup - reactiveBridge - (statusChange)" );
console.log( event );
console.groupEnd();
}
public handleModelGroupValueChange( event: ReactiveBridgeEvent ) : void {
console.group( "ngModelGroup - reactiveBridge - (valueChange)" );
console.log( event );
console.groupEnd();
}
public handleModelStatusChange( event: ReactiveBridgeEvent ) : void {
console.group( "ngModel - reactiveBridge - (statusChange)" );
console.log( event );
console.groupEnd();
}
public handleModelValueChange( event: ReactiveBridgeEvent ) : void {
console.group( "ngModel - reactiveBridge - (valueChange)" );
console.log( event );
console.groupEnd();
}
// NOTE: This isn't part of the reactive-bridge. This is just the normal submit
// functionality of the NgForm directive.
public handleSubmit( event: NgForm ) : void {
console.group( "ngForm(submit)" );
console.log( event );
console.log( JSON.stringify( this.form, null, 4 ) );
console.groupEnd();
}
}
Now, we if run this template-driven Angular app in the browser, we get the following output:
As you can see, the (valueChange) and (statusChange) events fired on component load for each of the NgForm, NgModelGroup, and NgModel template instances. And, if I change the input (pet's name) from "Lucy" to "Maggie", we can see that subsequent events fire up the chain of nested reactive elements:
Ok, let's look at how this is actually working - let's look at how the ReactiveEventBridgeDirective is tapping into the underlying reactive events and making them available to declarative event-bindings in my template.
Getting this to work wasn't exactly straightforward. Or, rather, I should say that getting this to work for NgForm and NgModel was easy; but, getting it to work with NgModelGroup was not.
It turns out that the underlying "control" and event-streams are not available in NgModelGroup right away. Unlike the NgForm and NgModel directives, which create "controls" as part of their own initialization, NgModelGroup appears to follow some sort of asynchronous workflow when registering itself with the parent "container". The reasons for this are beyond my understanding.
That said, the NgModelGroup directives seems to create its underlying FormGroup instance in the resolution of a Promise. As such, to mirror this timing, I am also using the resolution of a Promise. However, I can't use this approach for all three directives otherwise there appears to be some sort of race condition. As such, for NgForm and NgModel, I configure the EventEmitters right away; and, for NgModelGroup, I configure the EventEmitters after a Promise resolution.
It's a little janky; but, ultimately, it's not that much code:
// Import the core angular services.
import { AbstractControl } from "@angular/forms";
import { AbstractControlDirective } from "@angular/forms";
import { Directive } from "@angular/core";
import { EventEmitter } from "@angular/core";
import { NgForm } from "@angular/forms";
import { NgModel } from "@angular/forms";
import { NgModelGroup } from "@angular/forms";
import { Optional } from "@angular/core";
import { Self } from "@angular/core";
import { Subscription } from "rxjs";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export interface ReactiveBridgeEvent {
type: "statusChange" | "valueChange";
target: AbstractControl;
currentValue: any;
previousValue: any | undefined;
}
@Directive({
selector: "form[reactiveBridge],[ngModelGroup][reactiveBridge],[ngModel][reactiveBridge]",
outputs: [
"statusChangeEvents: statusChange",
"valueChangeEvents: valueChange"
],
exportAs: "reactiveBridge"
})
export class ReactiveBridgeDirective {
public statusChangeEvents: EventEmitter<ReactiveBridgeEvent>;
public valueChangeEvents: EventEmitter<ReactiveBridgeEvent>;
private control: AbstractControl | null;
private controlDirective: AbstractControlDirective;
private isDestroyed: boolean;
private previousStatus: any | undefined;
private previousValue: any | undefined;
private subscriptions: Subscription[];
// I initialize the reactive-bridge directive.
// --
// NOTE: Since this directive can be applied to three different types of elements,
// we're going to injected all three in the SELF scope and just use whichever one
// is defined.
constructor(
@Self() @Optional() ngForm: NgForm,
@Self() @Optional() ngModelGroup: NgModelGroup,
@Self() @Optional() ngModel: NgModel
) {
this.controlDirective = ( ngForm || ngModelGroup || ngModel ) !;
this.control = null;
this.isDestroyed = false;
this.previousStatus = undefined;
this.previousValue = undefined;
this.statusChangeEvents = new EventEmitter();
this.subscriptions = [];
this.valueChangeEvents = new EventEmitter();
}
// ---
// PUBLIC METHODS.
// ---
// I get called when the directive is being destroyed.
public ngOnDestroy() : void {
this.isDestroyed = true;
for ( var subscription of this.subscriptions ) {
subscription.unsubscribe();
}
}
// I get called once after the inputs have been bound for the first time.
public ngOnInit() : void {
// Since the NgForm and NgModel directives create internal controls as part of
// their initialization, the underlying control will be available immediately.
if ( this.controlDirective.control ) {
this.control = this.controlDirective.control;
this.setupSubscriptions();
return;
}
// If we made it this far, we're dealing with the NgModelGroup. Unlike the other
// form directives, this one has to register itself with the form asynchronously
// for reasons that I cannot fully understand when reading the Angular source
// code. That said, it seems that deferring the initialization with a Promise
// aligns with the workflow that the NgModelGroup directive is using internally.
// --
// NOTE: If we tried to initialize all three types of directives inside the same
// Promise-based workflow, the NgModelGroup wouldn't fire on form-load. I have
// no idea why. I assume it is a weird race-condition somewhere.
Promise.resolve().then(
() => {
// If the Promise resolves after the directive is destroyed, skip the
// subscriptions configuration.
if ( this.isDestroyed ) {
return;
}
this.control = this.controlDirective.control;
this.setupSubscriptions();
}
);
}
// ---
// PRIVATE METHODS.
// ---
// I setup the subscriptions on the underlying control's Reactive streams so that we
// can power the EventEmitters on the bridge.
private setupSubscriptions() : void {
this.subscriptions.push(
this.control.statusChanges.subscribe(
( event ) => {
this.statusChangeEvents.emit({
type: "statusChange",
target: this.control,
previousValue: this.previousStatus,
currentValue: event
});
this.previousStatus = event;
}
),
this.control.valueChanges.subscribe(
( event ) => {
this.valueChangeEvents.emit({
type: "valueChange",
target: this.control,
previousValue: this.previousValue,
currentValue: event
});
this.previousValue = event;
}
)
);
}
}
As you can see, the ReactiveEventBridgeDirective exposes two EventEmitter outputs that emit "currentValue" and "previousValue" snapshots. These EventEmitters are then being fed by the underlying statusChanges and valueChanges reactive streams. This is how we are making the Reactive Form events available within our template-driven forms.
NOTE: Template-driven forms already make (submit) and (ngModelChange) events available, which you can see in my App component's templates. That's why this event-bridge only focuses on (valueChange) and (statusChange).
Honestly, it seems a little peculiar that these events aren't already being exposed in the template-driven directives. The NgForm, NgModelGroup, and NgModel directives do expose getters for things like "valid", "dirty", "errors", and other state-based conditions. But, no direct-path to reactive events. At least, none that I could see (and easily leverage). Perhaps I'm just missing something.
If nothing else, this was just a fun experiment that demonstrates the power and flexibility of Angular Directives. It's so cool to see that we can use directives to seamlessly enhance the behavior of our component templates. We can even use directive to enhance the behavior of other directives (just as we are doing in this demo). It's just exciting!
Want to use code from this post? Check out the license.
Reader Comments
This is an interesting experiment.
I think Angular wanted to change direction, which is why these events are NOT exposed to Template driven forms. I guess they were hoping everyone would adopt the Reactive paradigm. If this was the case, they probably should have gone the whole way and deprecated the Template paradigm.
I'm actually glad they haven't, because I think that there are use cases for having both!
@Charles,
Yeah, I'm really glad they have both! I wish I had a better mental model for some of the advanced validation approaches - I bet they would really save me a lot of effort in some situations. I will continue to think about this, and maybe play a bit more with validation.
Thanks, Ben. This is excellent. This directive is just what I needed.