Second Experiment With Controlled Inputs (ala ReactJS) In Angular 2 Beta 11
Yesterday, I experimented with trying to create a ReactJS-inspired "controlled input" in Angular 2 Beta 11. The approach mostly worked; but, it was buggy, overly complicated, and typing fast had a noticeable lag to it. After trying a few alternate approaches, I think I finally came up with a controlled input approach that is very responsive and much more stable.
Run this demo in my JavaScript Demos project on GitHub.
To recap the point of yesterday's post, a "controlled input," to borrow from the ReactJS vernacular, is an input that is completely driven by a one-way data flow. Out of the box, Angular 2 doesn't support one-way data flow for form inputs. So, to make inputs comply with a one-way data flow architecture, we have to create a custom directive that binds to the input and takes over management of the value binding.
My approach yesterday sort of worked. But, it was complicated and buggy; if you typed too fast, it would end up corrupting the text cursor location. In this new approach - outlined in this post - I start out by assuming that the value will be accepted by the calling context. In this way, I'm optimizing for the majority use-case and then taking extra steps to ensure that the minority use-case, in which the input changes are rejected by the calling context, is also upheld.
Another change that I made in this approach is that I'm tracking the text cursor location though its own event binding. This way, I don't have to try and calculate the location based on the input change - I just grab the exact location every time the user goes to interact with the active element.
The final change that I made was that I converted the output "valueChange" EventEmitter to be synchronous instead of asynchronous (the default behavior). This allows the calling context's event handler to be invoked synchronously instead of in the next tick of the application. This provides a more predictable flow of data, allowing the input value binding to be updated at a more useful point in the change detection life-cycle.
I think you'll find that these changes make the code significantly simpler (in addition to being much more stable and performant). In the following code, you'll see that I'm allowing the (input) changes to remain in place by default, only reverting the input rendering if that assumption turns out to be false later on in the change detection life-cycle.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>
Second Experiment With Controlled Inputs (ala ReactJS) In Angular 2 Beta 11
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></lin>
</head>
<body>
<h1>
Second Experiment With Controlled Inputs (ala ReactJS) In Angular 2 Beta 11
</h1>
<my-app>
Loading...
</my-app>
<!-- Load demo scripts. -->
<script type="text/javascript" src="../../vendor/angularjs-2-beta/11/es6-shim.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs-2-beta/11/Rx.umd.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs-2-beta/11/angular2-polyfills.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs-2-beta/11/angular2-all.umd.js"></script>
<!-- AlmondJS - minimal implementation of RequireJS. -->
<script type="text/javascript" src="../../vendor/angularjs-2-beta/11/almond.js"></script>
<script type="text/javascript">
// Defer bootstrapping until all of the components have been declared.
requirejs(
[ /* Using require() for better readability. */ ],
function run() {
ng.platform.browser.bootstrap( require( "App" ) );
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide the root application component.
define(
"App",
function registerApp() {
// Configure the App component definition.
ng.core
.Component({
selector: "my-app",
// Here, we're providing a directive that turns the input and
// the textarea into "controlled inputs".
directives: [ require( "ControlledInput" ) ],
// In this template, we have two different Controller Inputs
// that are going to be rendered using the same value. While the
// user can type into the two inputs, the value won't actually
// change unless the [value] input property is updated.
template:
`
<input
[value]="message"
(valueChange)="handleMessage( $event )"
/>
<textarea
[value]="message"
(valueChange)="handleMessage( $event )">
</textarea>
<p>
<strong>Note:</strong> Inputs ignore numeric characters.
</p>
`
})
.Class({
constructor: AppController
})
;
return( AppController );
// I control the App component.
function AppController() {
var vm = this;
// I am the message being rendered in the two inputs. I "control" the
// value of the input, regardless of what the user types.
vm.message = "Hello world!";
// Expose the public methods.
vm.handleMessage = handleMessage;
// ---
// PUBLIC METHODS.
// ---
// I handle the "valueChange" event emitted by the controlled inputs.
// This event gives us a chance to pipe the emitted value back into
// the property that controls the input.
function handleMessage( newMessage ) {
// In this case, we're going to prevent the user from entering
// numeric digits into the input.
// --
// NOTE: If the user enters ONLY a numeric character, it means
// that the [value] won't actually change, which means that the
// ngOnChanges() event handler in the controlled input won't
// actually be invoked.
vm.message = newMessage.replace( /[0-9]+/g, "" );
}
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I turn non-ngModel inputs and textareas into "controlled inputs."
// --
// CAUTION: This is NOT a COMPLETE SOLUTION. This is just an experiment, looking
// at what a "controlled input" might look like in Angular 2. Take this with a
// grain of one-way salt.
define(
"ControlledInput",
function registerControlledInput() {
// Configure the ControlledInput directive definition.
ng.core
.Directive({
// Notice that our selector will fail on inputs that have
// [ngModel] bindings. This is because [ngModel] already breaks
// the one-way data flow, creating an "uncontrolled" component.
selector: "input[value]:not([ngModel]) , textarea[value]:not([ngModel])",
inputs: [ "value" ],
outputs: [ "valueChange" ],
host: {
"(input)": "handleInput( $event.target.value )",
"(keydown)": "trackSelection()"
}
})
.Class({
constructor: ControlledInputController,
// Define the life-cycle methods on the prototype so that they
// are picked up at run-time.
ngAfterContentChecked: function noop() {},
ngOnChanges: function noop() {}
})
;
ControlledInputController.parameters = [
new ng.core.Inject( ng.core.ElementRef )
];
return( ControlledInputController );
// I control the ControlledInput component.
function ControlledInputController( elementRef ) {
var vm = this;
// Whenever we reset the currently active Input element, we want to
// create a better user experience by trying to keep the user's text
// cursor in the right place. To do this, we will keep track of the
// text cursor as the user starts every interaction. This way, if an
// interaction is rejected by the calling context, we can move the
// text cursor back to its original position.
var selectionStart = 0;
// By default, we're going to assume that every interaction with the
// input will be kept / accepted by the calling context. As such,
// we'll need to check to see if the bound Input property was
// subsequently updated to match the rendered value during change
// detection. Keeping the rendered value in a variable (as opposed
// to just the DOM element) saves us from having to access the DOM
// as the source of truth.
var renderedValue = null;
// I hold the value for the controlled input.
vm.value = ""; // @Input to be injected.
// I am the event stream for the valueChange output.
// --
// CAUTION: We are creating a SYNCHRONOUS EventEmitter. This means
// that the calling context's handler will be called immediately as
// opposed to during the next tick of the application.
vm.valueChange = new ng.core.EventEmitter( /* isAsync = */ false );
// Expose the public methods.
vm.handleInput = handleInput;
vm.ngAfterContentChecked = checkRenderedValue; // Unexpected method reference!
vm.ngOnChanges = checkRenderedValue; // Unexpected method reference!
vm.trackSelection = trackSelection;
// ---
// PUBLIC METHODS.
// ---
// I check to see make sure that the input binding matches the
// rendered value. And, if it doesn't, I reset the rendered value to
// match the input binding.
// --
// CAUTION: This method is being used to power several different
// life-cycle methods:
// * ngOnChanges()
// * ngAfterContentChecked()
function checkRenderedValue() {
if ( vm.value !== renderedValue ) {
revertRenderedValue();
}
}
// I handle the input event for the controlled input. This is a
// synchronous event that happens as the input value is changing.
function handleInput( newValue ) {
// Emit and track the newly rendered value. We are tracking the
// value simply so we don't have to read the value out of the DOM
// Input element during the subsequent change detection.
// --
// CAUTION: We have setup our EventEmitter to be SYNCHRONOUS.
vm.valueChange.next( renderedValue = newValue );
}
// As the user interacts with the input field, I track the current
// current offset of the cursor within the text content.
function trackSelection() {
// CAUTION: Since the Renderer has no way to access properties
// on the nativeElement, we have no choice but to bypass the
// Renderer abstraction and couple ourselves to the Browser
// DOM (Document Object Model).
selectionStart = elementRef.nativeElement.selectionStart;
}
// ---
// PRIVATE METHODS.
// ---
// I reset the rendered element value to match the input binding.
function revertRenderedValue() {
elementRef.nativeElement.value = renderedValue = vm.value;
// If this is the currently active element, we also need to reset
// the location of the cursor.
// --
// NOTE: Setting the selection implicitly grants focus which is
// why we have to be careful to only apply to the active element.
if ( document.activeElement === elementRef.nativeElement ) {
elementRef.nativeElement.selectionStart = selectionStart;
elementRef.nativeElement.selectionEnd = selectionStart;
}
}
}
}
);
</script>
</body>
</html>
As with the previous approach, I'm still heavily coupled to the Browser DOM (Document Object Model). Since the Renderer abstraction doesn't provide a way to access native element properties - only set them - it means that I can't use the Renderer to get the selectionStart text cursor value. This, in turn, means that I might was well bypass the Renderer abstraction entirely and just use the Browser DOM API.
Unfortunately, this demo can't be illustrated easily with a graphic, so I suggest running the demo yourself or watching the video.
As I stated in my last post, I don't know how much I actually care about "controlled inputs." In theory, it's nice to have a complete one-way data flow architecture. But, pragmatically speaking, when it comes to form input fields, I rarely have a need to override the user's input. Often times, it's just the opposite - I'll take whatever the user provides (using ngModel) and deal with it during the form processing. But, I do think this is a valuable exploration because it makes us think more deeply about one-way data flow, input bindings, and the change detection life-cycle being implemented by Angular 2 Beta 11.
Want to use code from this post? Check out the license.
Reader Comments