Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with: James Allen and Matt Gifford
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with: James Allen@CFJamesAllen ) and Matt Gifford@coldfumonkeh )

Experimenting With Controlled Inputs (ala ReactJS) In Angular 2 Beta 11

By Ben Nadel on

If you're a ReactJS developer, you're probably familiar with the concept of Inputs as "controlled components." In ReactJS, a controlled component does not maintain its own internal state and is rendered solely based on its incoming props. This behavior can be applied to form inputs as well, where the value that a user enters isn't actually applied to the DOM (Document Object Model) element until the calling context pipes the change event back into the controlled component. Even though Angular 2 supports one-way data flow for your custom components, it doesn't natively support one-way data flow for form inputs. As such, I wanted to experiment with how one might go about implementing a "controlled input" in Angular 2 Beta 11 using custom directives.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

In order for a controlled input to work, we need to have the input accept a value property and expose a valueChange event:

  • [value] - The value to render in the input.
  • (valueChange) - The event emitter for new input value candidates.

Notice that our valueChange output binding emits value "candidates". I'm specifically calling them "candidates" because there can be no assumption that the value candidate will actually be piped back into the value input binding. That's the whole point of a "controlled" input - it doesn't maintain any internal state; it is controlled completely by its input properties. Which means, it is controlled completely by its calling context.

If we tried to use the native [value] property binding for an input element, we'd quickly discover that discarded (input) events don't actually overwrite the input value. Imagine that you had an (input) handler that looked something like this:

  • function handleInput( newValue ) {
  •  
  • // Discard (input) event by resetting the [value] property.
  • this.value = this.value;
  •  
  • }

Here, it looks like we're resetting the [value] property after every keystroke. But, we're not. Since the "this.value" property isn't actually changing in the view-model, Angular 2 never sees it as "dirty" and therefore never pushes it back into the native Input element. This leaves the user's text changes in tact.

To convert the input element into a "controlled input," we have to step in and override the value-handling. This way, we can make sure that the value isn't changed directly by the user's action; and, instead, only indirectly by way of (valueChange) events. To do this, we're going to create a custom directive that binds to non-ngModel inputs using the selector:

input[value]:not([ngModel])

Here, we're saying that we want to match inputs that are using the [value] input binding but that are not also using the [ngModel] input binding. ngModel purposefully breaks "controlled inputs," so the two approaches cannot be used together.

NOTE: [ngModel] components probably never use a [value] binding. So, the use of ":not" here is somewhat superfluous; but, if nothing else, it makes us more conscious of what it means to be a "controlled input" and why ngModel inputs are not controlled.

Our custom directive then needs to discard (input) events internally and emit (valueChange) events with the desired text. On its own, this is actually simple. But, on its own, it creates a rather unpleasant experience for the user as it does nothing to maintain the position of the user's text selection cursor (ie, the carrot). As such, our custom directive also has to try to maintain the cursor position across the (valueChange) / [value] life-cycle.

Before we look at the code, I will say that I've never used the "selectionStart" and "selectionEnd" properties before. So, please keep that in mind when looking at my implementation. There may very well be better ways to accomplish this - this was just an experiment.

I also have a slightly different approach that I wanted to try as a follow-up to this.

That said, let's take a look at the code. In the following demo, I have two "controlled inputs" - an Input element and a Textarea element. They both use the same [value] binding and (valueChange) event handler. In the (valueChange) event handler, notice that I'm scrubbing numeric characters out of the emitted value. Since this is a controlled input, any attempt to type numeric characters will be therefore ignored (ie, overridden by the calling context).

  • <!doctype html>
  • <html>
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Experimenting With Controlled Inputs (ala ReactJS) In Angular 2 Beta 11
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></lin>
  • </head>
  • <body>
  •  
  • <h1>
  • Experimenting 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 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 )"
  • }
  • })
  • .Class({
  • constructor: ControlledInputController,
  •  
  • // Define the life-cycle methods on the prototype so that they
  • // are picked up at run-time.
  • 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;
  •  
  • // As the user enters text into the controlled input, we want to keep
  • // track of the cursor selection so that we can try to re-implement
  • // it once the value input property is updated. The pendingValue will
  • // help us massage the selection for unexpected values.
  • var pendingValue = "";
  • var pendingSelectionStart = null;
  •  
  • // I hold the value for the controlled input. I determine what is
  • // rendered, regardless of what the user types.
  • vm.value = ""; // @Input to be injected.
  •  
  • // I am the event stream for the valueChange output.
  • vm.valueChange = new ng.core.EventEmitter();
  •  
  • // Expose the public methods.
  • vm.handleInput = handleInput;
  • vm.ngOnChanges = ngOnChanges;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // 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 ) {
  •  
  • // CAUTION: The Renderer service doesn't have methods for
  • // accessing the values of the nativeElement. As such, I am
  • // skipping the Renderer altogether and just accessing the
  • // nativeElement directly. I don't think I have a better choice
  • // in the matter.
  • pendingValue = newValue;
  • pendingSelectionStart = elementRef.nativeElement.selectionStart;
  •  
  • // Since we can't let the input change internally (otherwise it
  • // wouldn't be a "controlled" input) we're about to reset the
  • // value. But, the browser doesn't care about that - it will move
  • // the cursor to wherever the user stopped typing. As such, we
  • // need to compare the new / existing lengths in order to help
  • // keep the cursor in the appropriate place after the reset.
  • var newLength = elementRef.nativeElement.value.length;
  • var oldLength = vm.value.length;
  •  
  • // Reset the value - the value must be driven by the [value]
  • // input property binding.
  • elementRef.nativeElement.value = vm.value;
  •  
  • // Guestimate the right cursor position.
  • var restoredSelectionStart = ( newLength >= oldLength )
  • ? ( pendingSelectionStart - 1 )
  • : pendingSelectionStart
  • ;
  •  
  • // Reset the cursor.
  • elementRef.nativeElement.selectionStart = restoredSelectionStart;
  • elementRef.nativeElement.selectionEnd = restoredSelectionStart;
  •  
  • // Emit the value change so that the calling context has an
  • // opportunity to pipe the value change back into the controlled
  • // input component.
  • vm.valueChange.next( newValue );
  •  
  • }
  •  
  •  
  • // I get called whenever the input bindings change (or are set for
  • // the first time).
  • function ngOnChanges( changes ) {
  •  
  • // CAUTION: We are going to be messing with the nativeElement
  • // directly. I don't have a choice here - there is nothing in
  • // the Angular API (that I can see) that will allow me to READ
  • // the "selectionStart" property (which I have to do above). As
  • // such, I don't see any point in using the Renderer to SET the
  • // "selectionStart" or the "value" property. It would be extra
  • // effort for no reason. For this reason, I'm also grabbing the
  • // GLOBAL DOCUMENT OBJECT - I've already coupled myself to the
  • // browser DOM - no need to deny what this is.
  •  
  • // Push the value to the actual input control.
  • elementRef.nativeElement.value = vm.value;
  •  
  • // Only set the cursor if the element is currently focused
  • // (setting the selection will accidentally assign focus).
  • if (
  • ( pendingSelectionStart !== null ) &&
  • ( document.activeElement === elementRef.nativeElement )
  • ) {
  •  
  • // Because this is an input property binding, there's no
  • // reason to assume that the value we emitted is the same
  • // value that is being piped back in. As such, we're going
  • // to try an massage the cursor position based on any
  • // "unexpected" difference in value lengths.
  • var lengthDelta = ( changes.value.currentValue.length - pendingValue.length );
  • var selectionStart = ( pendingSelectionStart + lengthDelta );
  •  
  • // Try to maintain the correct cursor position after the
  • // input has been updated.
  • elementRef.nativeElement.selectionStart = selectionStart;
  • elementRef.nativeElement.selectionEnd = selectionStart;
  •  
  • // Reset our pending values.
  • pendingValue = "";
  • pendingSelectionStart = null;
  •  
  • }
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

I can't really create a graphic that demonstrates the controlled input behavior. For that, you'll have to watch the above video. But, trust me that numeric values are not allowed in the inputs.

As a follow-up post, I'd like to try tackling the same problem with the help of setTimeout() in order to create a slightly more organic user experience. But, for now, I think this exploration helps paint the picture of what a "controlled input" in Angular 2 might look like. It's definitely a non-trivial effort.

That said, I am not entirely sure how much I actually care about controlled inputs. Pragmatically speaking, I almost never want to ignore or override the user's input. So, the value-add of a controlled input is probably pretty small. But, considering that Angular 2 supports one-way data flow in general, there is something nice about supporting it consistently across all types of controls.




Reader Comments

I found a much simpler way to do this - same concept, but just much more straightforward code. Update coming soon.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.